mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-01-11 22:40:31 +00:00
Compare commits
547 commits
v5.0.0-bet
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da675cd56c | ||
|
|
9b3b0d67ba | ||
|
|
fc2d906a42 | ||
|
|
c15ca17d2d | ||
|
|
55963fd23e | ||
|
|
80066b2f3f | ||
|
|
c8dfc31e6b | ||
|
|
84a172d1bf | ||
|
|
6fbc08a720 | ||
|
|
2bc0f3468c | ||
|
|
c9a40aabd7 | ||
|
|
7046622fb6 | ||
|
|
5dc088b798 | ||
|
|
b5bd75fd94 | ||
|
|
16b2eb8d17 | ||
|
|
c4ab2dc546 | ||
|
|
227f21c10f | ||
|
|
d21be690de | ||
|
|
6c7a2755fb | ||
|
|
bfb5c484fc | ||
|
|
88fca500f1 | ||
|
|
058bb58bfb | ||
|
|
9a9cd2de12 | ||
|
|
4881f2c340 | ||
|
|
a744932949 | ||
|
|
8148a2f8fe | ||
|
|
6aef6e1d04 | ||
|
|
9cbfd15793 | ||
|
|
292cd9d03e | ||
|
|
cb74f3be65 | ||
|
|
f688a11751 | ||
|
|
1f93175e98 | ||
|
|
4b10795113 | ||
|
|
aa571a7f8f | ||
|
|
5e278a5244 | ||
|
|
17746db439 | ||
|
|
d86bc3bbd9 | ||
|
|
199b00b290 | ||
|
|
57b2632486 | ||
|
|
12c36f4df3 | ||
|
|
135ca80bd3 | ||
|
|
a037afd983 | ||
|
|
0c833330a1 | ||
|
|
074daeeae8 | ||
|
|
5fe0353be5 | ||
|
|
3c8d62f3b6 | ||
|
|
924cd715d2 | ||
|
|
4c407392dd | ||
|
|
5f1841bfb8 | ||
|
|
4fba2a3770 | ||
|
|
3bb7fc4dcc | ||
|
|
c7ccf39cfd | ||
|
|
b66c3654e5 | ||
|
|
1fec4bd0c5 | ||
|
|
67300d0159 | ||
|
|
9d81069398 | ||
|
|
3017af0df9 | ||
|
|
eac24c0360 | ||
|
|
af225b0135 | ||
|
|
c3f1f6c911 | ||
|
|
c0bc34eb40 | ||
|
|
75804bac10 | ||
|
|
a9c77da3c4 | ||
|
|
b876c920fc | ||
|
|
536be36005 | ||
|
|
000d5be639 | ||
|
|
00ebd6c4d0 | ||
|
|
7456e8f15a | ||
|
|
04e6780395 | ||
|
|
e316b07649 | ||
|
|
8ab582080d | ||
|
|
a35f7e7878 | ||
|
|
1b6f4d09d3 | ||
|
|
3579a99df3 | ||
|
|
3d163cf440 | ||
|
|
32cf4cc12e | ||
|
|
54bcdf6360 | ||
|
|
1b15f0b5f1 | ||
|
|
cbc708bc64 | ||
|
|
16877fa4bf | ||
|
|
af806bbfb1 | ||
|
|
6e65fa03d8 | ||
|
|
e3c4bc14bb | ||
|
|
5969bc9251 | ||
|
|
cf93c2dcbe | ||
|
|
c416971d22 | ||
|
|
72aa110d48 | ||
|
|
309956b237 | ||
|
|
2f566f8626 | ||
|
|
20c7ba672a | ||
|
|
00ae74e9af | ||
|
|
8c8d3376db | ||
|
|
7b2e5305e0 | ||
|
|
585a84ccd6 | ||
|
|
e7b0a1d1be | ||
|
|
eb61ad6943 | ||
|
|
18617b32c9 | ||
|
|
0433da66c1 | ||
|
|
2776741e8c | ||
|
|
3e0308dff1 | ||
|
|
4361792cae | ||
|
|
83752eb647 | ||
|
|
5c3b2b0b22 | ||
|
|
0143bf914c | ||
|
|
cf73c7942d | ||
|
|
91fbfc1178 | ||
|
|
56b60beedb | ||
|
|
d2d28be6de | ||
|
|
a97dd01869 | ||
|
|
e74072ebd5 | ||
|
|
9923152de7 | ||
|
|
3eff7f0903 | ||
|
|
122e43dbe5 | ||
|
|
910242b201 | ||
|
|
2e1ad64d02 | ||
|
|
4860a028c2 | ||
|
|
9e99a2b308 | ||
|
|
62a650018b | ||
|
|
fb9497a856 | ||
|
|
539a7ebc10 | ||
|
|
9fa0e46423 | ||
|
|
b40ef9f3dc | ||
|
|
b05f28cc54 | ||
|
|
f2c7382729 | ||
|
|
a3a7e14d15 | ||
|
|
c35c7c06e9 | ||
|
|
d8904bdb5a | ||
|
|
06365262d1 | ||
|
|
b7863bf319 | ||
|
|
2dcc582cc2 | ||
|
|
d832a9c136 | ||
|
|
4a1b0d3287 | ||
|
|
23c4587564 | ||
|
|
10faa1c105 | ||
|
|
593c8d55d0 | ||
|
|
9e33186708 | ||
|
|
cd980af475 | ||
|
|
f44fa98502 | ||
|
|
a56d3aafd3 | ||
|
|
5edbc899ea | ||
|
|
ca3a2774d2 | ||
|
|
a70828f168 | ||
|
|
2dee307ac3 | ||
|
|
d38cf32773 | ||
|
|
a0615bda42 | ||
|
|
ad5ab5c634 | ||
|
|
e78866a77d | ||
|
|
19c6e042fb | ||
|
|
57571cf1fc | ||
|
|
670f119027 | ||
|
|
49c11973d6 | ||
|
|
4a9c0fe5b4 | ||
|
|
c26dac2154 | ||
|
|
f39944373b | ||
|
|
ec34a84154 | ||
|
|
39bdb374e1 | ||
|
|
ecca656c68 | ||
|
|
c3d6b315d5 | ||
|
|
1e241c7926 | ||
|
|
67f5446030 | ||
|
|
f3a14403de | ||
|
|
90f834e893 | ||
|
|
10a98fcecf | ||
|
|
5bea8a83c6 | ||
|
|
010f2e0390 | ||
|
|
cf3119b0a0 | ||
|
|
83bb34e505 | ||
|
|
cfa99f0e38 | ||
|
|
3de03989bd | ||
|
|
3f685173c1 | ||
|
|
872243fc5c | ||
|
|
185740c834 | ||
|
|
b9b79a833d | ||
|
|
ed0ca136d1 | ||
|
|
6aabd75d5e | ||
|
|
8005cd849a | ||
|
|
138e3c0d48 | ||
|
|
4001ea3acc | ||
|
|
88ed546414 | ||
|
|
f271c97502 | ||
|
|
9e55bc8273 | ||
|
|
fd675e5926 | ||
|
|
672dbdeb28 | ||
|
|
d177f86018 | ||
|
|
be072e8391 | ||
|
|
8000a7089a | ||
|
|
f9b059d9e4 | ||
|
|
36721b40f1 | ||
|
|
4eb297a4f2 | ||
|
|
178974ddfd | ||
|
|
80f25b8d45 | ||
|
|
665cf7dd2a | ||
|
|
53dfddec74 | ||
|
|
3c2f8cb89b | ||
|
|
0bec58b158 | ||
|
|
20bbe12a8a | ||
|
|
3c2914aca2 | ||
|
|
746e5ba0d8 | ||
|
|
0e3aa9c2c8 | ||
|
|
40c77d3d17 | ||
|
|
41863e5d75 | ||
|
|
a9b9631241 | ||
|
|
85fea50c15 | ||
|
|
e98bdf2023 | ||
|
|
fb3f8d6918 | ||
|
|
59953e991d | ||
|
|
5adc0937dd | ||
|
|
71bf98dac8 | ||
|
|
083ec3a12e | ||
|
|
4419eeb33f | ||
|
|
05371f3617 | ||
|
|
4ad0fb2962 | ||
|
|
b742e385ea | ||
|
|
6c0d5288d3 | ||
|
|
c042c553e3 | ||
|
|
1b09119e52 | ||
|
|
595dfb22a3 | ||
|
|
aef0ecb5be | ||
|
|
4632d6e09a | ||
|
|
f04948240a | ||
|
|
cff57d7d59 | ||
|
|
c2a2eca4e9 | ||
|
|
76e8aa9091 | ||
|
|
3083b812be | ||
|
|
8a82ff083a | ||
|
|
066819d283 | ||
|
|
0b16f1c80e | ||
|
|
8bd0456e09 | ||
|
|
8c985619b8 | ||
|
|
1780b49a38 | ||
|
|
63aecc6764 | ||
|
|
c1c08cdfa1 | ||
|
|
8f0b58f38e | ||
|
|
8821eaf4a1 | ||
|
|
9f56557c79 | ||
|
|
306dd09f24 | ||
|
|
64c05e92eb | ||
|
|
5d88ff4212 | ||
|
|
80fc8c755f | ||
|
|
ee5269e1c8 | ||
|
|
b75d20971c | ||
|
|
652a042a55 | ||
|
|
f7f97b551c | ||
|
|
3cc6066a12 | ||
|
|
125a2650f8 | ||
|
|
4207fb52d6 | ||
|
|
d326cd5052 | ||
|
|
5eff16695c | ||
|
|
41a5bb7cef | ||
|
|
cbe3a5d35e | ||
|
|
c726398402 | ||
|
|
7fb0a8c1be | ||
|
|
f49a243009 | ||
|
|
ec1e098c99 | ||
|
|
f2490ee775 | ||
|
|
75bb1b0489 | ||
|
|
b4f49c3637 | ||
|
|
1d1b84bcd0 | ||
|
|
df897c6389 | ||
|
|
5ba7622f72 | ||
|
|
a7a36d6f11 | ||
|
|
e4c917ff20 | ||
|
|
5f82d6e9da | ||
|
|
f4a9c88c68 | ||
|
|
ab7fa8748a | ||
|
|
3aa421f768 | ||
|
|
703514b02d | ||
|
|
19178c8ac3 | ||
|
|
5fe16b8268 | ||
|
|
f35b726359 | ||
|
|
8ba2c4741e | ||
|
|
1c1163888e | ||
|
|
a3c895dfc6 | ||
|
|
00d89aec75 | ||
|
|
2e5958e957 | ||
|
|
6cca3a549c | ||
|
|
82783a4de7 | ||
|
|
f920ab48f3 | ||
|
|
37de79a0dc | ||
|
|
a77cf17133 | ||
|
|
3e91f55d22 | ||
|
|
409267cb44 | ||
|
|
5f085d259c | ||
|
|
00bac0aca2 | ||
|
|
cf654ae825 | ||
|
|
c30129c9e2 | ||
|
|
1b7d618a89 | ||
|
|
74a319285c | ||
|
|
48a6da5ca7 | ||
|
|
1789ddd06a | ||
|
|
485501c95b | ||
|
|
cb405878a0 | ||
|
|
7ec7e8eb03 | ||
|
|
29e7522f82 | ||
|
|
f6d4e3f4a6 | ||
|
|
1e710c4d46 | ||
|
|
5f106f49d3 | ||
|
|
a0d3a50122 | ||
|
|
dfaba09ef2 | ||
|
|
f7f9e6a408 | ||
|
|
dad52d61ed | ||
|
|
86bd1b276a | ||
|
|
4dcdff7700 | ||
|
|
d5eb6a515f | ||
|
|
524bcd90da | ||
|
|
7dc0958e39 | ||
|
|
40871dc8f2 | ||
|
|
73823e9e07 | ||
|
|
48d95d9d6f | ||
|
|
cbd0e87729 | ||
|
|
df365a431d | ||
|
|
f4d02ac151 | ||
|
|
b7f75d1bbe | ||
|
|
21ad21c82e | ||
|
|
69047471bd | ||
|
|
bbe966bc88 | ||
|
|
52e2b23912 | ||
|
|
dff998d723 | ||
|
|
a5ffa2677d | ||
|
|
945a6d16b1 | ||
|
|
7c3dd67eb9 | ||
|
|
2f0ec456fe | ||
|
|
fb3d4e29fa | ||
|
|
bd5a8e988f | ||
|
|
d329139abe | ||
|
|
48851a62cb | ||
|
|
64707dee21 | ||
|
|
1297a2926b | ||
|
|
40907e9448 | ||
|
|
6c7035db9c | ||
|
|
4df2c4c9ca | ||
|
|
3dbcff6fb9 | ||
|
|
ecfaa518a0 | ||
|
|
be73839349 | ||
|
|
dc9cfb12f3 | ||
|
|
38d5290d91 | ||
|
|
beb873e34e | ||
|
|
ad680ca2a5 | ||
|
|
faee4166c3 | ||
|
|
d75c9b1d99 | ||
|
|
eab1b8def3 | ||
|
|
a162397d29 | ||
|
|
fd4c9e73c8 | ||
|
|
015e770e42 | ||
|
|
75f647e3a4 | ||
|
|
824763a277 | ||
|
|
8768b9ced3 | ||
|
|
d692d09041 | ||
|
|
e704abcb03 | ||
|
|
d27f92fb01 | ||
|
|
4f9cd61286 | ||
|
|
66ad1ea59f | ||
|
|
bafa6a7ad2 | ||
|
|
67c1b814c3 | ||
|
|
83a7f6fd3d | ||
|
|
0875b89e5e | ||
|
|
8968055493 | ||
|
|
5d9a005686 | ||
|
|
597b366ce2 | ||
|
|
41546d65d2 | ||
|
|
18b70402a4 | ||
|
|
ce54ac9aac | ||
|
|
3c249e5925 | ||
|
|
51a1da958b | ||
|
|
fe871f03f1 | ||
|
|
5a942c5f73 | ||
|
|
a7eb1801e3 | ||
|
|
a25d23559f | ||
|
|
7b57c0f508 | ||
|
|
db40abfad3 | ||
|
|
61fdf3113e | ||
|
|
64c553d253 | ||
|
|
b3bd68eb32 | ||
|
|
2b44367a26 | ||
|
|
19bf6abb00 | ||
|
|
6dfa3fdae0 | ||
|
|
e8ac50135b | ||
|
|
789173bb5b | ||
|
|
5f81804b00 | ||
|
|
2de0a517e0 | ||
|
|
f24ad7d069 | ||
|
|
aba31c8ceb | ||
|
|
657f9cd29e | ||
|
|
718a64877c | ||
|
|
4d82c2f890 | ||
|
|
42a55a254d | ||
|
|
bed2a58060 | ||
|
|
5f53b9b44a | ||
|
|
7b7c700533 | ||
|
|
fa07709d31 | ||
|
|
28578e1dea | ||
|
|
2e72f5af9d | ||
|
|
01c5100aaf | ||
|
|
440713ee68 | ||
|
|
38f7e5a0f8 | ||
|
|
365294946a | ||
|
|
b4c0ab551e | ||
|
|
fff0ebe85d | ||
|
|
1d8401e4df | ||
|
|
a6f84d18d1 | ||
|
|
1ae009c0bc | ||
|
|
1d95b9efbd | ||
|
|
62f8bb367f | ||
|
|
878af40c1d | ||
|
|
cc105f327c | ||
|
|
252de8c496 | ||
|
|
2dec01923a | ||
|
|
f8ab1a7dbc | ||
|
|
4d53952368 | ||
|
|
b51791baa0 | ||
|
|
17312f64fd | ||
|
|
ebb15463b4 | ||
|
|
3985c88346 | ||
|
|
f3a7ef5978 | ||
|
|
ce0c5da3fd | ||
|
|
d6372c4f86 | ||
|
|
79f06153c8 | ||
|
|
f446047f8b | ||
|
|
9b405c53d8 | ||
|
|
9aed64d998 | ||
|
|
672a0067ce | ||
|
|
18ac3583b4 | ||
|
|
7924200dab | ||
|
|
980d0038ec | ||
|
|
0efd1453bb | ||
|
|
6bfe079030 | ||
|
|
5b56c58e5b | ||
|
|
107564b9d4 | ||
|
|
242a4a8110 | ||
|
|
aeb9265e3b | ||
|
|
22ba03dea2 | ||
|
|
1568ba1bb2 | ||
|
|
d09c760a1c | ||
|
|
63624a9554 | ||
|
|
fb7c5642b0 | ||
|
|
1b43484013 | ||
|
|
ad4df3bac5 | ||
|
|
1247fc18f7 | ||
|
|
0809fbdf53 | ||
|
|
6faf70d33e | ||
|
|
69ee1da68e | ||
|
|
3f631b1b72 | ||
|
|
a8eb284379 | ||
|
|
55dac0d36f | ||
|
|
e6bd8d66e8 | ||
|
|
a8931d94df | ||
|
|
57d16957f2 | ||
|
|
303dd9858b | ||
|
|
3aac148258 | ||
|
|
66abce42c5 | ||
|
|
0744fdfb8d | ||
|
|
922de40134 | ||
|
|
52dc7722ad | ||
|
|
e454cecc45 | ||
|
|
468dc604ae | ||
|
|
0c3b7e8d4a | ||
|
|
befcef6dd2 | ||
|
|
09c1b0c45e | ||
|
|
43c76b6a4b | ||
|
|
5acc32411d | ||
|
|
2ce1619313 | ||
|
|
2713c8b46d | ||
|
|
b86887e111 | ||
|
|
846445001c | ||
|
|
50edda2557 | ||
|
|
a1acb7423a | ||
|
|
97eaa5f00a | ||
|
|
6b9a3a91c7 | ||
|
|
fdfc939abb | ||
|
|
96f0baadc2 | ||
|
|
200d3a76d8 | ||
|
|
d457db6f1e | ||
|
|
a0fa5e2a92 | ||
|
|
592fb17fa1 | ||
|
|
9a6f01b6ad | ||
|
|
0eefcb4782 | ||
|
|
2ef3f52c1c | ||
|
|
3530e3c7d0 | ||
|
|
9aa0490989 | ||
|
|
7c932a93e5 | ||
|
|
639d5f8d1c | ||
|
|
e156c27b64 | ||
|
|
4eca979d97 | ||
|
|
7915424fa4 | ||
|
|
b2f5fb74c8 | ||
|
|
cdaad5c834 | ||
|
|
60ba559500 | ||
|
|
56762353f2 | ||
|
|
7636beabdc | ||
|
|
3f58bb8e46 | ||
|
|
f5fb2ed37a | ||
|
|
7147c954c9 | ||
|
|
a96a44b0dd | ||
|
|
db7277714b | ||
|
|
a21e5698c8 | ||
|
|
5365c1739e | ||
|
|
98784779b5 | ||
|
|
7f244c4fdd | ||
|
|
4b64439271 | ||
|
|
79d9e886be | ||
|
|
5f8aaf395d | ||
|
|
48e07c3008 | ||
|
|
20577e2431 | ||
|
|
86c29f2201 | ||
|
|
ad260f3ded | ||
|
|
7ea974f1da | ||
|
|
794f4e48ac | ||
|
|
a50e3c7186 | ||
|
|
0506d53f4e | ||
|
|
1b17bc81cb | ||
|
|
b8c328507e | ||
|
|
224b6e6f76 | ||
|
|
b1365e31d4 | ||
|
|
2816f7bcea | ||
|
|
dad4804bad | ||
|
|
6999ef6a8d | ||
|
|
675328ca08 | ||
|
|
07cc2a9b2d | ||
|
|
4cd9db53d1 | ||
|
|
f678375633 | ||
|
|
10a36d2c4d | ||
|
|
aa3dedf8be | ||
|
|
232c64b613 | ||
|
|
37020f340c | ||
|
|
3dcd0020c9 | ||
|
|
d7974babdd | ||
|
|
6274115f8f | ||
|
|
7a79e31f95 | ||
|
|
1721810467 | ||
|
|
3bc075d0a1 | ||
|
|
3c2ab92bd6 | ||
|
|
3f60df9073 | ||
|
|
6ca94a2124 | ||
|
|
f7494d6e97 | ||
|
|
fbdfa110b5 | ||
|
|
d407e6c7b7 | ||
|
|
0c9b9927cc | ||
|
|
34808d6014 | ||
|
|
6b30b90893 | ||
|
|
5e98355896 | ||
|
|
7fa4f462c5 | ||
|
|
36a2896525 | ||
|
|
538e462b12 | ||
|
|
39f168a34c | ||
|
|
461c9d3d53 | ||
|
|
15c6a231a6 | ||
|
|
e8a6e72b13 |
220 changed files with 15696 additions and 17934 deletions
66
.github/workflows/auto_assign.yml
vendored
Normal file
66
.github/workflows/auto_assign.yml
vendored
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
name: PR and Issue Workflow
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
issues:
|
||||
types: [opened]
|
||||
jobs:
|
||||
auto-assign-and-label:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
# Auto assign PR to author
|
||||
- name: Auto Assign PR to Author
|
||||
if: github.event.pull_request.head.repo.fork == false && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
if (pr) {
|
||||
await github.rest.issues.addAssignees({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pr.number,
|
||||
assignees: [pr.user.login]
|
||||
});
|
||||
console.log(`Assigned PR #${pr.number} to author @${pr.user.login}`);
|
||||
}
|
||||
|
||||
# Dynamic labeling based on PR/Issue title
|
||||
- name: Label PRs and Issues
|
||||
if: github.event.pull_request.head.repo.fork == false && github.actor != 'dependabot[bot]'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const prTitle = context.payload.pull_request ? context.payload.pull_request.title : context.payload.issue.title;
|
||||
const issueNumber = context.payload.pull_request ? context.payload.pull_request.number : context.payload.issue.number;
|
||||
const isIssue = context.payload.issue !== undefined;
|
||||
const labelMappings = [
|
||||
{ pattern: /^feat(ure)?/i, label: 'feature' },
|
||||
{ pattern: /^fix/i, label: 'bug' },
|
||||
{ pattern: /^refactor/i, label: 'refactor' },
|
||||
{ pattern: /^chore/i, label: 'chore' },
|
||||
{ pattern: /^docs?/i, label: 'documentation' },
|
||||
{ pattern: /^perf(ormance)?/i, label: 'performance' },
|
||||
{ pattern: /^test/i, label: 'testing' }
|
||||
];
|
||||
let labelsToAdd = [];
|
||||
for (const mapping of labelMappings) {
|
||||
if (mapping.pattern.test(prTitle)) {
|
||||
labelsToAdd.push(mapping.label);
|
||||
}
|
||||
}
|
||||
if (labelsToAdd.length > 0) {
|
||||
github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
labels: labelsToAdd
|
||||
});
|
||||
}
|
||||
22
.github/workflows/build.yml
vendored
22
.github/workflows/build.yml
vendored
|
|
@ -5,7 +5,7 @@ on:
|
|||
branches:
|
||||
- development
|
||||
tags-ignore:
|
||||
- '**'
|
||||
- "**"
|
||||
pull_request:
|
||||
branches:
|
||||
- development
|
||||
|
|
@ -20,20 +20,26 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: "pnpm"
|
||||
- name: Install NPM dependencies
|
||||
run: npm ci
|
||||
run: pnpm install
|
||||
- name: Build
|
||||
run: npm run build
|
||||
run: pnpm build
|
||||
- name: Test
|
||||
run: npm test
|
||||
run: pnpm test
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
# Create recursivelly the destiantion dir with
|
||||
run: pnpm lint
|
||||
# Create recursively the destination dir with
|
||||
# "--parrents where no error if existing, make parent directories as needed."
|
||||
- run: mkdir -p ./build/${{ github.head_ref || github.ref_name }}
|
||||
- name: Deploy to GitHub Pages
|
||||
|
|
|
|||
53
.github/workflows/pages_cleanup.yml
vendored
Normal file
53
.github/workflows/pages_cleanup.yml
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
name: GitHub Pages Cleanup
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: gh-pages
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Delete directories that don't have existing branch
|
||||
run: |
|
||||
branches=( $(git branch -r | grep origin | grep -v HEAD | sed 's|origin/||') )
|
||||
declare -p branches
|
||||
|
||||
find . -mindepth 1 -maxdepth 2 -type d -not -path '*/\.*' | while read -r dir; do
|
||||
path="${dir#./}"
|
||||
|
||||
if [[ " ${branches[*]} " =~ " $path " ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
keep_parent=false
|
||||
for branch in "${branches[@]}"; do
|
||||
if [[ "$branch" == "$path/"* ]]; then
|
||||
keep_parent=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if ! $keep_parent; then
|
||||
echo "Deleting $dir"
|
||||
rm -rf "$dir"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config --global user.name 'GitHub Pages Cleanup'
|
||||
git config --global user.email 'actions@stremio.com'
|
||||
git add -A
|
||||
git diff --cached --quiet || git commit -m "cleanup"
|
||||
git push origin gh-pages
|
||||
21
.github/workflows/release.yml
vendored
21
.github/workflows/release.yml
vendored
|
|
@ -9,26 +9,25 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Install NPM dependencies
|
||||
run: npm install
|
||||
uses: actions/checkout@v6
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
run: npm run build
|
||||
run: pnpm build
|
||||
- name: Zip build artifact
|
||||
run: zip -r stremio-web.zip ./build
|
||||
- name: Upload build artifact to GitHub release assets
|
||||
uses: svenstaro/upload-release-action@2.9.0
|
||||
uses: svenstaro/upload-release-action@2.11.3
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: stremio-web.zip
|
||||
asset_name: stremio-web.zip
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
- name: Upload build artifact to Netlify
|
||||
run: |
|
||||
curl -H "Content-Type: application/zip" \
|
||||
-H "Authorization: Bearer ${{ secrets.netlify_access_token }}" \
|
||||
--data-binary "@stremio-web.zip" \
|
||||
https://api.netlify.com/api/v1/sites/stremio-development.netlify.com/deploys
|
||||
|
|
|
|||
|
|
@ -1,67 +1,26 @@
|
|||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "9EWRZ4QP3J.com.stremio.one",
|
||||
"paths": [
|
||||
"/",
|
||||
"/#/player/*",
|
||||
"/#/discover/*",
|
||||
"/#/detail/*",
|
||||
"/#/library/*",
|
||||
"/#/addons/*",
|
||||
"/#/settings",
|
||||
"/#/search/*"
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"/": "/",
|
||||
"#": "/player/*",
|
||||
"comment": "Matches deep link for player"
|
||||
},
|
||||
{
|
||||
"/": "/",
|
||||
"#": "/discover/*",
|
||||
"comment": "Matches deep link for discover"
|
||||
},
|
||||
{
|
||||
"/": "/",
|
||||
"#": "/detail/*",
|
||||
"comment": "Matches deep link for detail"
|
||||
},
|
||||
{
|
||||
"/": "/",
|
||||
"#": "/library/*",
|
||||
"comment": "Matches deep link for library"
|
||||
},
|
||||
{
|
||||
"/": "/",
|
||||
"#": "/addons/*",
|
||||
"comment": "Matches deep link for addons"
|
||||
},
|
||||
{
|
||||
"/": "/",
|
||||
"#": "/settings",
|
||||
"comment": "Matches deep link for settings"
|
||||
},
|
||||
{
|
||||
"/": "/",
|
||||
"#": "/search/*",
|
||||
"comment": "Matches deep link for search"
|
||||
}
|
||||
]
|
||||
}
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appIDs": [
|
||||
"9EWRZ4QP3J.com.stremio.one"
|
||||
],
|
||||
"appID": "9EWRZ4QP3J.com.stremio.one",
|
||||
"paths": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"activitycontinuation": {
|
||||
"apps": [
|
||||
"9EWRZ4QP3J.com.stremio.one"
|
||||
]
|
||||
},
|
||||
"webcredentials": {
|
||||
"apps": [
|
||||
"9EWRZ4QP3J.com.stremio.one"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"activitycontinuation": {
|
||||
"apps": [
|
||||
"9EWRZ4QP3J.com.stremio.one"
|
||||
]
|
||||
},
|
||||
"webcredentials": {
|
||||
"apps": [
|
||||
"9EWRZ4QP3J.com.stremio.one"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -2,35 +2,42 @@
|
|||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and community a harassment-free experience for everyone, regardless of age, level of experience, education, nationality or race.
|
||||
We as contributors and maintainers want to make contributing to our project and community a nice experience for everyone.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment include:
|
||||
Examples of positive behavior:
|
||||
|
||||
- Using welcoming and inclusive language.
|
||||
- Being respectful of differing viewpoints and experiences.
|
||||
- Using welcoming language.
|
||||
- Being respectful.
|
||||
- Accepting constructive criticism.
|
||||
- Focusing on what is best for the community.
|
||||
- Showing empathy towards other community members.
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
Examples of bad behavior:
|
||||
|
||||
- The use of sexualized language or imagery and unwelcome sexual attention or advances.
|
||||
- Trolling, insulting/derogatory comments, and personal or political attacks.
|
||||
- Use of sexualized language.
|
||||
- Trolling, insulting comments, and personal or political attacks.
|
||||
- Public or private harassment.
|
||||
- Publishing others’ private information, such as a physical or electronic address, without explicit permission.
|
||||
- Other conduct which could reasonably be considered inappropriate in a professional setting.
|
||||
- Submitting entirely generated by AI PRs with agents such as Devin, Claude Code, Cursor Agent etc.
|
||||
- Submitting PRs which in majority contain only AI generated code (including docs & comments) and do not solve an actual issue.
|
||||
- Spamming issues because of no ETAs on issues.
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
||||
Project maintainers are responsible for enforcing this code of conduct. They can remove or edit comments, code, and other contributions that don't follow these rules. They can also ban users who behave inappropriately.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, pull requests, and other contributions that do not align with this Code of Conduct, as well as to temporarily or permanently ban any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
## Suggestions for newbies
|
||||
|
||||
- Contributors are welcomed to use AI models as "help" in solving issues, but you must always double check the code that you're submitting.
|
||||
- Refrain from excessive comments generated by AI.
|
||||
- Refrain from docs generated entirely by AI.
|
||||
- Always check what files you are committing and submitting to the PR when you are using any agent for help or an AI model.
|
||||
- If you don't know how to tackle a problem and AI can't help you, please just ask or look in Stack Overlflow, Google, Medium etc.
|
||||
- Learning how to code is fun and easier when using AI, but sometimes it might be just too much ... what are you going to learn, if AI does everything for you and you don't know what the code you are submitting actually does?!
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all `stremio-web` spaces, and also applies when an individual is officially representing the project or its community in public spaces.
|
||||
This Code of Conduct applies everywhere in `stremio-web` repository, and also applies when an individual is officially representing the project or its community in other spaces.
|
||||
|
||||
## Enforcement
|
||||
|
||||
|
|
|
|||
38
Dockerfile
38
Dockerfile
|
|
@ -3,29 +3,39 @@
|
|||
ARG NODE_VERSION=20-alpine
|
||||
FROM node:$NODE_VERSION AS base
|
||||
|
||||
# Setup pnpm
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Meta
|
||||
LABEL Description="Stremio Web" Vendor="Smart Code OOD" Version="1.0.0"
|
||||
|
||||
RUN mkdir -p /var/www/stremio-web
|
||||
WORKDIR /var/www/stremio-web
|
||||
|
||||
# Install app dependencies
|
||||
FROM base AS prebuild
|
||||
# Setup app
|
||||
FROM base AS app
|
||||
|
||||
RUN apk update && apk upgrade && \
|
||||
apk add --no-cache git
|
||||
WORKDIR /var/www/stremio-web
|
||||
COPY . .
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
COPY package.json pnpm-lock.yaml /var/www/stremio-web
|
||||
RUN pnpm i --frozen-lockfile
|
||||
|
||||
# Bundle app source
|
||||
FROM base AS final
|
||||
COPY . /var/www/stremio-web
|
||||
RUN pnpm build
|
||||
|
||||
WORKDIR /var/www/stremio-web
|
||||
COPY . .
|
||||
COPY --from=prebuild /var/www/stremio-web/node_modules ./node_modules
|
||||
COPY --from=prebuild /var/www/stremio-web/build ./build
|
||||
# Setup server
|
||||
FROM base AS server
|
||||
|
||||
RUN pnpm i express@4
|
||||
|
||||
# Finalize
|
||||
FROM base
|
||||
|
||||
COPY http_server.js /var/www/stremio-web
|
||||
COPY --from=server /var/www/stremio-web/node_modules /var/www/stremio-web/node_modules
|
||||
COPY --from=app /var/www/stremio-web/build /var/www/stremio-web/build
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["node", "http_server.js"]
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -1,6 +1,6 @@
|
|||
# Stremio - Freedom to Stream
|
||||
|
||||

|
||||
[](https://github.com/Stremio/stremio-web/actions/workflows/build.yml)
|
||||
[](https://stremio.github.io/stremio-web/development)
|
||||
|
||||
Stremio is a modern media center that's a one-stop solution for your video entertainment. You discover, watch and organize video content from easy to install addons.
|
||||
|
|
@ -10,24 +10,31 @@ Stremio is a modern media center that's a one-stop solution for your video enter
|
|||
### Prerequisites
|
||||
|
||||
* Node.js 12 or higher
|
||||
* npm 6 or higher
|
||||
* [pnpm](https://pnpm.io/installation) 10 or higher
|
||||
|
||||
### Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Start development server
|
||||
|
||||
```bash
|
||||
npm start
|
||||
pnpm start
|
||||
```
|
||||
|
||||
### Production build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
### Run with Docker
|
||||
|
||||
```bash
|
||||
docker build -t stremio-web .
|
||||
docker run -p 8080:8080 stremio-web
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export default [
|
|||
'@stylistic/semi-spacing': 'error',
|
||||
'@stylistic/space-before-blocks': 'error',
|
||||
'@stylistic/no-trailing-spaces': 'error',
|
||||
'@stylistic/func-call-spacing': 'error',
|
||||
'@stylistic/function-call-spacing': 'error',
|
||||
'@stylistic/semi': 'error',
|
||||
'@stylistic/no-extra-semi': 'error',
|
||||
'@stylistic/eol-last': 'error',
|
||||
|
|
|
|||
15494
package-lock.json
generated
15494
package-lock.json
generated
File diff suppressed because it is too large
Load diff
20
package.json
20
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "stremio",
|
||||
"displayName": "Stremio",
|
||||
"version": "5.0.0-beta.21",
|
||||
"version": "5.0.0-beta.29",
|
||||
"author": "Smart Code OOD",
|
||||
"private": true,
|
||||
"license": "gpl-2.0",
|
||||
|
|
@ -10,15 +10,16 @@
|
|||
"start-prod": "webpack serve --mode production",
|
||||
"build": "webpack --mode production",
|
||||
"test": "jest",
|
||||
"lint": "eslint src"
|
||||
"lint": "eslint src",
|
||||
"scan-translations": "pnpx jest ./tests/i18nScan.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.26.0",
|
||||
"@sentry/browser": "8.42.0",
|
||||
"@stremio/stremio-colors": "5.2.0",
|
||||
"@stremio/stremio-core-web": "0.49.2",
|
||||
"@stremio/stremio-icons": "5.4.1",
|
||||
"@stremio/stremio-video": "0.0.53",
|
||||
"@stremio/stremio-core-web": "0.51.1",
|
||||
"@stremio/stremio-icons": "5.8.0",
|
||||
"@stremio/stremio-video": "0.0.64",
|
||||
"a-color-picker": "1.2.1",
|
||||
"bowser": "2.11.0",
|
||||
"buffer": "6.0.3",
|
||||
|
|
@ -40,7 +41,7 @@
|
|||
"react-i18next": "^15.1.3",
|
||||
"react-is": "18.3.1",
|
||||
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
|
||||
"stremio-translations": "github:Stremio/stremio-translations#a6be0425573917c2e82b66d28968c1a4d444cb96",
|
||||
"stremio-translations": "github:Stremio/stremio-translations#0e7fbd8522148f5727ac6adee3b2eb96132c10ac",
|
||||
"url": "0.11.4",
|
||||
"use-long-press": "^3.2.0"
|
||||
},
|
||||
|
|
@ -49,9 +50,11 @@
|
|||
"@babel/preset-env": "7.26.0",
|
||||
"@babel/preset-react": "7.26.3",
|
||||
"@eslint/js": "^9.16.0",
|
||||
"@stylistic/eslint-plugin": "^2.11.0",
|
||||
"@stylistic/eslint-plugin-jsx": "^2.11.0",
|
||||
"@stylistic/eslint-plugin": "^5.4.0",
|
||||
"@stylistic/eslint-plugin-jsx": "^4.4.1",
|
||||
"@types/hat": "^0.0.4",
|
||||
"@types/lodash.isequal": "^4.5.8",
|
||||
"@types/lodash.throttle": "^4.1.9",
|
||||
"@types/react": "^18.3.13",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"babel-loader": "9.2.1",
|
||||
|
|
@ -70,6 +73,7 @@
|
|||
"mini-css-extract-plugin": "2.9.2",
|
||||
"postcss-loader": "8.1.1",
|
||||
"readdirp": "4.0.2",
|
||||
"recast": "0.23.11",
|
||||
"terser-webpack-plugin": "5.3.10",
|
||||
"thread-loader": "^4.0.4",
|
||||
"ts-loader": "^9.5.1",
|
||||
|
|
|
|||
11030
pnpm-lock.yaml
Normal file
11030
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
Binary file not shown.
|
Before Width: | Height: | Size: 1 MiB After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 947 KiB After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.6 MiB |
|
|
@ -6,11 +6,12 @@ const { useTranslation } = require('react-i18next');
|
|||
const { Router } = require('stremio-router');
|
||||
const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services');
|
||||
const { NotFound } = require('stremio/routes');
|
||||
const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, CONSTANTS, withCoreSuspender, useShell } = require('stremio/common');
|
||||
const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, ShortcutsProvider, CONSTANTS, withCoreSuspender, useShell, useBinaryState } = require('stremio/common');
|
||||
const ServicesToaster = require('./ServicesToaster');
|
||||
const DeepLinkHandler = require('./DeepLinkHandler');
|
||||
const SearchParamsHandler = require('./SearchParamsHandler');
|
||||
const { default: UpdaterBanner } = require('./UpdaterBanner');
|
||||
const { default: ShortcutsModal } = require('./ShortcutsModal');
|
||||
const ErrorDialog = require('./ErrorDialog');
|
||||
const withProtectedRoutes = require('./withProtectedRoutes');
|
||||
const routerViewsConfig = require('./routerViewsConfig');
|
||||
|
|
@ -38,6 +39,14 @@ const App = () => {
|
|||
};
|
||||
}, []);
|
||||
const [initialized, setInitialized] = React.useState(false);
|
||||
const [shortcutModalOpen,, closeShortcutsModal, toggleShortcutModal] = useBinaryState(false);
|
||||
|
||||
const onShortcut = React.useCallback((name) => {
|
||||
if (name === 'shortcuts') {
|
||||
toggleShortcutModal();
|
||||
}
|
||||
}, [toggleShortcutModal]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let prevPath = window.location.hash.slice(1);
|
||||
const onLocationHashChange = () => {
|
||||
|
|
@ -102,12 +111,18 @@ const App = () => {
|
|||
// Handle shell events
|
||||
React.useEffect(() => {
|
||||
const onOpenMedia = (data) => {
|
||||
if (data.startsWith('stremio:///')) return;
|
||||
if (data.startsWith('stremio://')) {
|
||||
const transportUrl = data.replace('stremio://', 'https://');
|
||||
if (URL.canParse(transportUrl)) {
|
||||
window.location.href = `#/addons?addon=${encodeURIComponent(transportUrl)}`;
|
||||
try {
|
||||
const { protocol, hostname, pathname, searchParams } = new URL(data);
|
||||
if (protocol === CONSTANTS.PROTOCOL) {
|
||||
if (hostname.length) {
|
||||
const transportUrl = `https://${hostname}${pathname}`;
|
||||
window.location.href = `#/addons?addon=${encodeURIComponent(transportUrl)}`;
|
||||
} else {
|
||||
window.location.href = `#${pathname}?${searchParams.toString()}`;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to open media:', e);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -153,7 +168,8 @@ const App = () => {
|
|||
services.core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'PullUserFromAPI'
|
||||
action: 'PullUserFromAPI',
|
||||
args: {}
|
||||
}
|
||||
});
|
||||
services.core.transport.dispatch({
|
||||
|
|
@ -197,15 +213,20 @@ const App = () => {
|
|||
<ToastProvider className={styles['toasts-container']}>
|
||||
<TooltipProvider className={styles['tooltip-container']}>
|
||||
<FileDropProvider className={styles['file-drop-container']}>
|
||||
<ServicesToaster />
|
||||
<DeepLinkHandler />
|
||||
<SearchParamsHandler />
|
||||
<UpdaterBanner className={styles['updater-banner-container']} />
|
||||
<RouterWithProtectedRoutes
|
||||
className={styles['router']}
|
||||
viewsConfig={routerViewsConfig}
|
||||
onPathNotMatch={onPathNotMatch}
|
||||
/>
|
||||
<ShortcutsProvider onShortcut={onShortcut}>
|
||||
{
|
||||
shortcutModalOpen && <ShortcutsModal onClose={closeShortcutsModal}/>
|
||||
}
|
||||
<ServicesToaster />
|
||||
<DeepLinkHandler />
|
||||
<SearchParamsHandler />
|
||||
<UpdaterBanner className={styles['updater-banner-container']} />
|
||||
<RouterWithProtectedRoutes
|
||||
className={styles['router']}
|
||||
viewsConfig={routerViewsConfig}
|
||||
onPathNotMatch={onPathNotMatch}
|
||||
/>
|
||||
</ShortcutsProvider>
|
||||
</FileDropProvider>
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,13 @@ const SearchParamsHandler = () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'AddServerUrl',
|
||||
args: streamingServerUrl,
|
||||
},
|
||||
});
|
||||
toast.show({
|
||||
type: 'success',
|
||||
title: `Using streaming server at ${streamingServerUrl}`,
|
||||
|
|
|
|||
59
src/App/ShortcutsModal/ShortcutsModal.tsx
Normal file
59
src/App/ShortcutsModal/ShortcutsModal.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Icon from '@stremio/stremio-icons/react';
|
||||
import { useShortcuts } from 'stremio/common';
|
||||
import { Button, ShortcutsGroup } from 'stremio/components';
|
||||
import styles from './styles.less';
|
||||
|
||||
type Props = {
|
||||
onClose: () => void,
|
||||
};
|
||||
|
||||
const ShortcutsModal = ({ onClose }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { grouped } = useShortcuts();
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = ({ key }: KeyboardEvent) => {
|
||||
key === 'Escape' && onClose();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
|
||||
return createPortal((
|
||||
<div className={styles['shortcuts-modal']}>
|
||||
<div className={styles['backdrop']} onClick={onClose} />
|
||||
|
||||
<div className={styles['container']}>
|
||||
<div className={styles['header']}>
|
||||
<div className={styles['title']}>
|
||||
{t('SETTINGS_NAV_SHORTCUTS')}
|
||||
</div>
|
||||
|
||||
<Button className={styles['close-button']} title={t('BUTTON_CLOSE')} onClick={onClose}>
|
||||
<Icon className={styles['icon']} name={'close'} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles['content']}>
|
||||
{
|
||||
grouped.map(({ name, label, shortcuts }) => (
|
||||
<ShortcutsGroup
|
||||
key={name}
|
||||
label={label}
|
||||
shortcuts={shortcuts}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
), document.body);
|
||||
};
|
||||
|
||||
export default ShortcutsModal;
|
||||
2
src/App/ShortcutsModal/index.ts
Normal file
2
src/App/ShortcutsModal/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import ShortcutsModal from './ShortcutsModal';
|
||||
export default ShortcutsModal;
|
||||
91
src/App/ShortcutsModal/styles.less
Normal file
91
src/App/ShortcutsModal/styles.less
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
|
||||
|
||||
.shortcuts-modal {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: @color-background-dark5-40;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-height: 80%;
|
||||
max-width: 80%;
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--modal-background-color);
|
||||
box-shadow: var(--outer-glow);
|
||||
overflow-y: auto;
|
||||
|
||||
.header {
|
||||
flex: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 5rem;
|
||||
padding-left: 2.5rem;
|
||||
padding-right: 1rem;
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: relative;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
z-index: 2;
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--primary-foreground-color);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
.icon {
|
||||
opacity: 1;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--primary-foreground-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 3rem;
|
||||
padding: 0 2.5rem;
|
||||
padding-bottom: 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
@top-overlay-size: 5.25rem;
|
||||
@bottom-overlay-size: 0rem;
|
||||
@overlap-size: 3rem;
|
||||
@transparency-grandient-pad: 6rem;
|
||||
@transparency-gradient-pad: 6rem;
|
||||
|
||||
:root {
|
||||
--landscape-shape-ratio: 0.5625;
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
--color-x: #000000;
|
||||
--color-reddit: #FF4500;
|
||||
--color-imdb: #f5c518;
|
||||
--color-trakt: #ED2224;
|
||||
--color-trakt: rgb(255, 255, 255);
|
||||
--color-placeholder: #60606080;
|
||||
--color-placeholder-text: @color-surface-50;
|
||||
--color-placeholder-background: @color-surface-dark5-20;
|
||||
|
|
@ -69,7 +69,7 @@
|
|||
--top-overlay-size: @top-overlay-size;
|
||||
--bottom-overlay-size: @bottom-overlay-size;
|
||||
--overlap-size: @overlap-size;
|
||||
--transparency-grandient-pad: @transparency-grandient-pad;
|
||||
--transparency-gradient-pad: @transparency-gradient-pad;
|
||||
--safe-area-inset-top: @safe-area-inset-top;
|
||||
--safe-area-inset-right: @safe-area-inset-right;
|
||||
--safe-area-inset-bottom: @safe-area-inset-bottom;
|
||||
|
|
@ -151,14 +151,13 @@ svg {
|
|||
html {
|
||||
width: @html-width;
|
||||
height: @html-height;
|
||||
font-family: 'PlusJakartaSans', 'sans-serif';
|
||||
font-family: 'PlusJakartaSans', 'Arial', 'Helvetica', 'sans-serif';
|
||||
overflow: auto;
|
||||
overscroll-behavior: none;
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
|
||||
@media (display-mode: standalone) {
|
||||
width: @html-standalone-width;
|
||||
height: @html-standalone-height;
|
||||
|
|
@ -168,6 +167,7 @@ html {
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(41deg, var(--primary-background-color) 0%, var(--secondary-background-color) 100%);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
||||
:global(#app) {
|
||||
position: relative;
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ const EXTERNAL_PLAYERS = [
|
|||
|
||||
const WHITELISTED_HOSTS = ['stremio.com', 'strem.io', 'stremio.zendesk.com', 'google.com', 'youtube.com', 'twitch.tv', 'twitter.com', 'x.com', 'netflix.com', 'adex.network', 'amazon.com', 'forms.gle'];
|
||||
|
||||
const PROTOCOL = 'stremio:';
|
||||
|
||||
module.exports = {
|
||||
CHROMECAST_RECEIVER_APP_ID,
|
||||
DEFAULT_STREAMING_SERVER_URL,
|
||||
|
|
@ -127,4 +129,5 @@ module.exports = {
|
|||
SUPPORTED_LOCAL_SUBTITLES,
|
||||
EXTERNAL_PLAYERS,
|
||||
WHITELISTED_HOSTS,
|
||||
PROTOCOL,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const FileDropProvider = ({ className, children }: Props) => {
|
|||
.then((buffer) => {
|
||||
listeners
|
||||
.filter(([type]) => file.type ? type === file.type : isFileType(buffer, type))
|
||||
.forEach(([, listerner]) => listerner(file.name, buffer));
|
||||
.forEach(([, listener]) => listener(file.name, buffer));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React, { createContext, useContext } from 'react';
|
||||
import { WHITELISTED_HOSTS } from 'stremio/common/CONSTANTS';
|
||||
import useShell from 'stremio/common/useShell';
|
||||
import { name, isMobile } from './device';
|
||||
|
||||
interface PlatformContext {
|
||||
|
|
@ -16,19 +15,13 @@ type Props = {
|
|||
};
|
||||
|
||||
const PlatformProvider = ({ children }: Props) => {
|
||||
const shell = useShell();
|
||||
|
||||
const openExternal = (url: string) => {
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
const isWhitelisted = WHITELISTED_HOSTS.some((host: string) => hostname.endsWith(host));
|
||||
const finalUrl = !isWhitelisted ? `https://www.stremio.com/warning#${encodeURIComponent(url)}` : url;
|
||||
|
||||
if (shell.active) {
|
||||
shell.send('open-external', finalUrl);
|
||||
} else {
|
||||
window.open(finalUrl, '_blank');
|
||||
}
|
||||
window.open(finalUrl, '_blank');
|
||||
} catch (e) {
|
||||
console.error('Failed to parse external url:', e);
|
||||
}
|
||||
|
|
|
|||
67
src/common/Shortcuts/Shortcuts.tsx
Normal file
67
src/common/Shortcuts/Shortcuts.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import React, { createContext, useCallback, useContext, useEffect, useRef } from 'react';
|
||||
import shortcuts from './shortcuts.json';
|
||||
|
||||
const SHORTCUTS = shortcuts.map(({ shortcuts }) => shortcuts).flat();
|
||||
|
||||
export type ShortcutName = string;
|
||||
export type ShortcutListener = (combo: number) => void;
|
||||
|
||||
interface ShortcutsContext {
|
||||
grouped: ShortcutGroup[],
|
||||
on: (name: ShortcutName, listener: ShortcutListener) => void,
|
||||
off: (name: ShortcutName, listener: ShortcutListener) => void,
|
||||
}
|
||||
|
||||
const ShortcutsContext = createContext<ShortcutsContext>({} as ShortcutsContext);
|
||||
|
||||
type Props = {
|
||||
children: JSX.Element,
|
||||
onShortcut: (name: ShortcutName) => void,
|
||||
};
|
||||
|
||||
const ShortcutsProvider = ({ children, onShortcut }: Props) => {
|
||||
const listeners = useRef<Map<ShortcutName, Set<ShortcutListener>>>(new Map());
|
||||
|
||||
const onKeyDown = useCallback(({ ctrlKey, shiftKey, code, key }: KeyboardEvent) => {
|
||||
SHORTCUTS.forEach(({ name, combos }) => combos.forEach((keys) => {
|
||||
const modifers = (keys.includes('Ctrl') ? ctrlKey : true)
|
||||
&& (keys.includes('Shift') ? shiftKey : true);
|
||||
|
||||
if (modifers && (keys.includes(code) || keys.includes(key.toUpperCase()))) {
|
||||
const combo = combos.indexOf(keys);
|
||||
listeners.current.get(name)?.forEach((listener) => listener(combo));
|
||||
|
||||
onShortcut(name as ShortcutName);
|
||||
}
|
||||
}));
|
||||
}, [onShortcut]);
|
||||
|
||||
const on = (name: ShortcutName, listener: ShortcutListener) => {
|
||||
!listeners.current.has(name) && listeners.current.set(name, new Set());
|
||||
listeners.current.get(name)!.add(listener);
|
||||
};
|
||||
|
||||
const off = (name: ShortcutName, listener: ShortcutListener) => {
|
||||
listeners.current.get(name)?.delete(listener);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [onKeyDown]);
|
||||
|
||||
return (
|
||||
<ShortcutsContext.Provider value={{ grouped: shortcuts, on, off }}>
|
||||
{children}
|
||||
</ShortcutsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useShortcuts = () => {
|
||||
return useContext(ShortcutsContext);
|
||||
};
|
||||
|
||||
export {
|
||||
ShortcutsProvider,
|
||||
useShortcuts,
|
||||
};
|
||||
8
src/common/Shortcuts/index.ts
Normal file
8
src/common/Shortcuts/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { ShortcutsProvider, useShortcuts } from './Shortcuts';
|
||||
import onShortcut from './onShortcut';
|
||||
|
||||
export {
|
||||
ShortcutsProvider,
|
||||
useShortcuts,
|
||||
onShortcut,
|
||||
};
|
||||
15
src/common/Shortcuts/onShortcut.ts
Normal file
15
src/common/Shortcuts/onShortcut.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { DependencyList, useCallback, useEffect } from 'react';
|
||||
import { ShortcutListener, ShortcutName, useShortcuts } from './Shortcuts';
|
||||
|
||||
const onShortcut = (name: ShortcutName, listener: ShortcutListener, deps: DependencyList) => {
|
||||
const shortcuts = useShortcuts();
|
||||
|
||||
const listenerCallback = useCallback(listener, deps);
|
||||
|
||||
useEffect(() => {
|
||||
shortcuts.on(name, listenerCallback);
|
||||
return () => shortcuts.off(name, listenerCallback);
|
||||
}, [listenerCallback]);
|
||||
};
|
||||
|
||||
export default onShortcut;
|
||||
104
src/common/Shortcuts/shortcuts.json
Normal file
104
src/common/Shortcuts/shortcuts.json
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
[
|
||||
{
|
||||
"name": "general",
|
||||
"label": "SETTINGS_NAV_GENERAL",
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "navigateTabs",
|
||||
"label": "SETTINGS_SHORTCUT_NAVIGATE_MENUS",
|
||||
"combos": [["1", "2", "3", "4", "5", "6"]]
|
||||
},
|
||||
{
|
||||
"name": "navigateSearch",
|
||||
"label": "SETTINGS_SHORTCUT_GO_TO_SEARCH",
|
||||
"combos": [["0"]]
|
||||
},
|
||||
{
|
||||
"name": "fullscreen",
|
||||
"label": "SETTINGS_SHORTCUT_FULLSCREEN",
|
||||
"combos": [["F"]]
|
||||
},
|
||||
{
|
||||
"name": "exit",
|
||||
"label": "SETTINGS_SHORTCUT_EXIT_BACK",
|
||||
"combos": [["Escape"]]
|
||||
},
|
||||
{
|
||||
"name": "shortcuts",
|
||||
"label": "SETTINGS_SHORTCUT_SHORTCUTS",
|
||||
"combos": [["Ctrl", "/"]]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "player",
|
||||
"label": "SETTINGS_NAV_PLAYER",
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "playPause",
|
||||
"label": "SETTINGS_SHORTCUT_PLAY_PAUSE",
|
||||
"combos": [["Space"]]
|
||||
},
|
||||
{
|
||||
"name": "seekForward",
|
||||
"label": "SETTINGS_SHORTCUT_SEEK_FORWARD",
|
||||
"combos": [["ArrowRight"], ["Shift", "ArrowRight"]]
|
||||
},
|
||||
{
|
||||
"name": "seekBackward",
|
||||
"label": "SETTINGS_SHORTCUT_SEEK_BACKWARD",
|
||||
"combos": [["ArrowLeft"], ["Shift", "ArrowLeft"]]
|
||||
},
|
||||
{
|
||||
"name": "volumeUp",
|
||||
"label": "SETTINGS_SHORTCUT_VOLUME_UP",
|
||||
"combos": [["ArrowUp"]]
|
||||
},
|
||||
{
|
||||
"name": "volumeDown",
|
||||
"label": "SETTINGS_SHORTCUT_VOLUME_DOWN",
|
||||
"combos": [["ArrowDown"]]
|
||||
},
|
||||
{
|
||||
"name": "mute",
|
||||
"label": "SETTINGS_SHORTCUT_MUTE",
|
||||
"combos": [["M"]]
|
||||
},
|
||||
{
|
||||
"name": "subtitlesSize",
|
||||
"label": "SETTINGS_SHORTCUT_SUBTITLES_SIZE",
|
||||
"combos": [["-"], ["="]]
|
||||
},
|
||||
{
|
||||
"name": "subtitlesDelay",
|
||||
"label": "SETTINGS_SHORTCUT_SUBTITLES_DELAY",
|
||||
"combos": [["G"], ["H"]]
|
||||
},
|
||||
{
|
||||
"name": "subtitlesMenu",
|
||||
"label": "SETTINGS_SHORTCUT_MENU_SUBTITLES",
|
||||
"combos": [["S"]]
|
||||
},
|
||||
{
|
||||
"name": "audioMenu",
|
||||
"label": "SETTINGS_SHORTCUT_MENU_AUDIO",
|
||||
"combos": [["A"]]
|
||||
},
|
||||
{
|
||||
"name": "infoMenu",
|
||||
"label": "SETTINGS_SHORTCUT_MENU_INFO",
|
||||
"combos": [["I"]]
|
||||
},
|
||||
{
|
||||
"name": "speedMenu",
|
||||
"label": "SETTINGS_SHORTCUT_MENU_PLAYBACK_SPEED",
|
||||
"combos": [["R"]]
|
||||
},
|
||||
{
|
||||
"name": "statisticsMenu",
|
||||
"label": "SETTINGS_SHORTCUT_MENU_STATISTICS",
|
||||
"combos": [["D"]]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
11
src/common/Shortcuts/types.d.ts
vendored
Normal file
11
src/common/Shortcuts/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
type Shortcut = {
|
||||
name: string,
|
||||
label: string,
|
||||
combos: string[][],
|
||||
};
|
||||
|
||||
type ShortcutGroup = {
|
||||
name: string,
|
||||
label: string,
|
||||
shortcuts: Shortcut[],
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
|
|
@ -8,6 +9,7 @@ const { Button } = require('stremio/components');
|
|||
const styles = require('./styles');
|
||||
|
||||
const ToastItem = ({ title, message, dataset, onSelect, onClose, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
const type = React.useMemo(() => {
|
||||
return ['success', 'alert', 'info', 'error'].includes(props.type) ?
|
||||
props.type
|
||||
|
|
@ -74,7 +76,7 @@ const ToastItem = ({ title, message, dataset, onSelect, onClose, ...props }) =>
|
|||
null
|
||||
}
|
||||
</div>
|
||||
<Button className={styles['close-button-container']} title={'Close'} tabIndex={-1} onClick={closeButtonOnClick}>
|
||||
<Button className={styles['close-button-container']} title={t('BUTTON_CLOSE')} tabIndex={-1} onClick={closeButtonOnClick}>
|
||||
<Icon className={styles['icon']} name={'close'} />
|
||||
</Button>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
&.error {
|
||||
.icon-container {
|
||||
.icon {
|
||||
color: var(--color-trakt);
|
||||
color: var(--danger-accent-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
11
src/common/Toast/useToast.d.ts
vendored
Normal file
11
src/common/Toast/useToast.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
type ToastOptions = {
|
||||
type: string,
|
||||
title: string,
|
||||
timeout: number,
|
||||
};
|
||||
|
||||
declare const useToast: () => {
|
||||
show: (options: ToastOptions) => void,
|
||||
};
|
||||
|
||||
export = useToast;
|
||||
|
|
@ -82,6 +82,19 @@
|
|||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.fade-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-active {
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s cubic-bezier(0.32, 0, 0.67, 0);
|
||||
}
|
||||
|
||||
.fade-exit {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes fade-in-no-motion {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const { FileDropProvider, onFileDrop } = require('./FileDrop');
|
|||
const { PlatformProvider, usePlatform } = require('./Platform');
|
||||
const { ToastProvider, useToast } = require('./Toast');
|
||||
const { TooltipProvider, Tooltip } = require('./Tooltips');
|
||||
const { ShortcutsProvider, useShortcuts, onShortcut } = require('./Shortcuts');
|
||||
const comparatorWithPriorities = require('./comparatorWithPriorities');
|
||||
const CONSTANTS = require('./CONSTANTS');
|
||||
const { withCoreSuspender, useCoreSuspender } = require('./CoreSuspender');
|
||||
|
|
@ -15,6 +16,7 @@ const routesRegexp = require('./routesRegexp');
|
|||
const useAnimationFrame = require('./useAnimationFrame');
|
||||
const useBinaryState = require('./useBinaryState');
|
||||
const { default: useFullscreen } = require('./useFullscreen');
|
||||
const { default: useInterval } = require('./useInterval');
|
||||
const useLiveRef = require('./useLiveRef');
|
||||
const useModelState = require('./useModelState');
|
||||
const useNotifications = require('./useNotifications');
|
||||
|
|
@ -23,15 +25,20 @@ const useProfile = require('./useProfile');
|
|||
const { default: useSettings } = require('./useSettings');
|
||||
const { default: useShell } = require('./useShell');
|
||||
const useStreamingServer = require('./useStreamingServer');
|
||||
const { default: useTimeout } = require('./useTimeout');
|
||||
const useTorrent = require('./useTorrent');
|
||||
const useTranslate = require('./useTranslate');
|
||||
const { default: useOrientation } = require('./useOrientation');
|
||||
const { default: useLanguageSorting } = require('./useLanguageSorting');
|
||||
|
||||
module.exports = {
|
||||
FileDropProvider,
|
||||
onFileDrop,
|
||||
PlatformProvider,
|
||||
usePlatform,
|
||||
ShortcutsProvider,
|
||||
useShortcuts,
|
||||
onShortcut,
|
||||
ToastProvider,
|
||||
useToast,
|
||||
TooltipProvider,
|
||||
|
|
@ -48,6 +55,7 @@ module.exports = {
|
|||
useAnimationFrame,
|
||||
useBinaryState,
|
||||
useFullscreen,
|
||||
useInterval,
|
||||
useLiveRef,
|
||||
useModelState,
|
||||
useNotifications,
|
||||
|
|
@ -56,7 +64,9 @@ module.exports = {
|
|||
useSettings,
|
||||
useShell,
|
||||
useStreamingServer,
|
||||
useTimeout,
|
||||
useTorrent,
|
||||
useTranslate,
|
||||
useOrientation,
|
||||
useLanguageSorting,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -51,6 +51,10 @@
|
|||
"name": "فارسی",
|
||||
"codes": ["fa-IR", "fas"]
|
||||
},
|
||||
{
|
||||
"name": "Suomi",
|
||||
"codes": ["fi-FI", "fin"]
|
||||
},
|
||||
{
|
||||
"name": "Français",
|
||||
"codes": ["fr-FR", "fre"]
|
||||
|
|
@ -119,13 +123,17 @@
|
|||
"name": "português",
|
||||
"codes": ["pt-PT", "por"]
|
||||
},
|
||||
{
|
||||
"name": "Română",
|
||||
"codes": ["ro-RO", "ron"]
|
||||
},
|
||||
{
|
||||
"name": "русский язык",
|
||||
"codes": ["ru-RU", "rus"]
|
||||
},
|
||||
{
|
||||
"name": "Svenska",
|
||||
"codes": ["sv-SE", "swe"]
|
||||
"name": "Slovenčina",
|
||||
"codes": ["sk-SK", "slk"]
|
||||
},
|
||||
{
|
||||
"name": "slovenski jezik",
|
||||
|
|
@ -135,6 +143,10 @@
|
|||
"name": "српски језик",
|
||||
"codes": ["sr-RS", "srp"]
|
||||
},
|
||||
{
|
||||
"name": "Svenska",
|
||||
"codes": ["sv-SE", "swe"]
|
||||
},
|
||||
{
|
||||
"name": "తెలుగు",
|
||||
"codes": ["te-IN", "tel"]
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const routesRegexp = {
|
|||
urlParamsNames: []
|
||||
},
|
||||
board: {
|
||||
regexp: /^\/?$/,
|
||||
regexp: /^\/?(?:board)?$/,
|
||||
urlParamsNames: []
|
||||
},
|
||||
discover: {
|
||||
|
|
|
|||
|
|
@ -10,11 +10,15 @@ const useFullscreen = () => {
|
|||
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
const requestFullscreen = useCallback(() => {
|
||||
const requestFullscreen = useCallback(async () => {
|
||||
if (shell.active) {
|
||||
shell.send('win-set-visibility', { fullscreen: true });
|
||||
} else {
|
||||
document.documentElement.requestFullscreen();
|
||||
try {
|
||||
await document.documentElement.requestFullscreen();
|
||||
} catch (err) {
|
||||
console.error('Error enabling fullscreen', err);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -22,7 +26,9 @@ const useFullscreen = () => {
|
|||
if (shell.active) {
|
||||
shell.send('win-set-visibility', { fullscreen: false });
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
if (document.fullscreenElement === document.documentElement) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -40,10 +46,24 @@ const useFullscreen = () => {
|
|||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
|
||||
const inputFocused =
|
||||
activeElement &&
|
||||
(activeElement.tagName === 'INPUT' ||
|
||||
activeElement.tagName === 'TEXTAREA' ||
|
||||
activeElement.tagName === 'SELECT' ||
|
||||
activeElement.isContentEditable);
|
||||
|
||||
if (event.code === 'Escape' && settings.escExitFullscreen) {
|
||||
exitFullscreen();
|
||||
}
|
||||
|
||||
if (event.code === 'KeyF' && !inputFocused) {
|
||||
toggleFullscreen();
|
||||
}
|
||||
|
||||
if (event.code === 'F11' && shell.active) {
|
||||
toggleFullscreen();
|
||||
}
|
||||
|
|
@ -58,7 +78,7 @@ const useFullscreen = () => {
|
|||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
||||
};
|
||||
}, [settings.escExitFullscreen]);
|
||||
}, [settings.escExitFullscreen, toggleFullscreen]);
|
||||
|
||||
return [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen];
|
||||
};
|
||||
|
|
|
|||
26
src/common/useInterval.ts
Normal file
26
src/common/useInterval.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const useInterval = (duration: number) => {
|
||||
const interval = useRef<NodeJS.Timer | null>(null);
|
||||
|
||||
const start = (callback: () => void) => {
|
||||
cancel();
|
||||
interval.current = setInterval(callback, duration);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
interval.current && clearInterval(interval.current);
|
||||
interval.current = null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => cancel();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
start,
|
||||
cancel,
|
||||
};
|
||||
};
|
||||
|
||||
export default useInterval;
|
||||
38
src/common/useLanguageSorting.ts
Normal file
38
src/common/useLanguageSorting.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { useMemo } from 'react';
|
||||
import interfaceLanguages from 'stremio/common/interfaceLanguages.json';
|
||||
|
||||
const useLanguageSorting = (options: MultiselectMenuOption[]) => {
|
||||
const userLangCode = useMemo(() => {
|
||||
const lang = interfaceLanguages.find((l) => l.codes.includes(navigator.language || 'en-US'));
|
||||
if (lang) {
|
||||
const threeLetter = lang.codes[1] || 'eng';
|
||||
const fullLocale = navigator.language || 'en-US';
|
||||
return [threeLetter, fullLocale];
|
||||
}
|
||||
return ['eng'];
|
||||
}, []);
|
||||
|
||||
const isLanguageDropdown = useMemo(() => {
|
||||
return options?.some((opt) => interfaceLanguages.some((l) => l.name === opt.label));
|
||||
}, [options]);
|
||||
|
||||
const sortedOptions = useMemo(() => {
|
||||
const matchingIndex = options.findIndex((opt) => {
|
||||
const lang = interfaceLanguages.find((l) => l.name === opt.label);
|
||||
return userLangCode.some((code) => lang?.codes.includes(code));
|
||||
});
|
||||
|
||||
if (matchingIndex === -1) {
|
||||
return [...options].sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
const matchingOption = options[matchingIndex];
|
||||
const otherOptions = options.filter((_, idx) => idx !== matchingIndex).sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
return [matchingOption, ...otherOptions];
|
||||
}, [options, userLangCode, isLanguageDropdown]);
|
||||
|
||||
return { userLangCode, isLanguageDropdown, sortedOptions };
|
||||
};
|
||||
|
||||
export default useLanguageSorting;
|
||||
4
src/common/useNotifications.d.ts
vendored
4
src/common/useNotifications.d.ts
vendored
|
|
@ -1,2 +1,2 @@
|
|||
declare const useNotifcations: () => Notifications;
|
||||
export = useNotifcations;
|
||||
declare const useNotifications: () => Notifications;
|
||||
export = useNotifications;
|
||||
|
|
|
|||
26
src/common/useTimeout.ts
Normal file
26
src/common/useTimeout.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const useTimeout = (duration: number) => {
|
||||
const timeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const start = (callback: () => void) => {
|
||||
cancel();
|
||||
timeout.current = setTimeout(callback, duration);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
timeout.current && clearTimeout(timeout.current);
|
||||
timeout.current = null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => cancel();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
start,
|
||||
cancel,
|
||||
};
|
||||
};
|
||||
|
||||
export default useTimeout;
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
|
|
@ -8,6 +9,7 @@ const { default: Image } = require('stremio/components/Image');
|
|||
const styles = require('./styles');
|
||||
|
||||
const AddonDetails = ({ className, id, name, version, logo, description, types, transportUrl, official }) => {
|
||||
const { t } = useTranslation();
|
||||
const renderLogoFallback = React.useCallback(() => (
|
||||
<Icon className={styles['icon']} name={'addons'} />
|
||||
), []);
|
||||
|
|
@ -24,7 +26,7 @@ const AddonDetails = ({ className, id, name, version, logo, description, types,
|
|||
<span className={styles['name']}>{typeof name === 'string' && name.length > 0 ? name : id}</span>
|
||||
{
|
||||
typeof version === 'string' && version.length > 0 ?
|
||||
<span className={styles['version']}>v. {version}</span>
|
||||
<span className={styles['version']}>{t('ADDON_VERSION_SHORT', {version})}</span>
|
||||
:
|
||||
null
|
||||
}
|
||||
|
|
@ -41,7 +43,7 @@ const AddonDetails = ({ className, id, name, version, logo, description, types,
|
|||
{
|
||||
typeof transportUrl === 'string' && transportUrl.length > 0 ?
|
||||
<div className={styles['section-container']}>
|
||||
<span className={styles['section-header']}>URL: </span>
|
||||
<span className={styles['section-header']}>{`${t('URL')}:`}</span>
|
||||
<span className={classnames(styles['section-label'], styles['transport-url-label'])}>{transportUrl}</span>
|
||||
</div>
|
||||
:
|
||||
|
|
@ -50,7 +52,7 @@ const AddonDetails = ({ className, id, name, version, logo, description, types,
|
|||
{
|
||||
Array.isArray(types) && types.length > 0 ?
|
||||
<div className={styles['section-container']}>
|
||||
<span className={styles['section-header']}>Supported types: </span>
|
||||
<span className={styles['section-header']}>{`${t('ADDON_SUPPORTED_TYPES')}:`} </span>
|
||||
<span className={styles['section-label']}>
|
||||
{
|
||||
types.length === 1 ?
|
||||
|
|
@ -66,7 +68,7 @@ const AddonDetails = ({ className, id, name, version, logo, description, types,
|
|||
{
|
||||
!official ?
|
||||
<div className={styles['section-container']}>
|
||||
<div className={classnames(styles['section-label'], styles['disclaimer-label'])}>Using third-party add-ons will always be subject to your responsibility and the governing law of the jurisdiction you are located.</div>
|
||||
<div className={classnames(styles['section-label'], styles['disclaimer-label'])}>{t('ADDON_DISCLAIMER')}</div>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const ModalDialog = require('stremio/components/ModalDialog');
|
||||
const { withCoreSuspender } = require('stremio/common/CoreSuspender');
|
||||
|
|
@ -43,13 +44,14 @@ function withRemoteAndLocalAddon(AddonDetails) {
|
|||
}
|
||||
|
||||
const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
const platform = usePlatform();
|
||||
const addonDetails = useAddonDetails(transportUrl);
|
||||
const modalButtons = React.useMemo(() => {
|
||||
const cancelButton = {
|
||||
className: styles['cancel-button'],
|
||||
label: 'Cancel',
|
||||
label: t('BUTTON_CANCEL'),
|
||||
props: {
|
||||
onClick: (event) => {
|
||||
if (typeof onCloseRequest === 'function') {
|
||||
|
|
@ -67,7 +69,7 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
|
|||
addonDetails.remoteAddon.content.content.manifest.behaviorHints.configurable ?
|
||||
{
|
||||
className: styles['configure-button'],
|
||||
label: 'Configure',
|
||||
label: t('ADDON_CONFIGURE'),
|
||||
props: {
|
||||
onClick: (event) => {
|
||||
platform.openExternal(transportUrl.replace('manifest.json', 'configure'));
|
||||
|
|
@ -86,7 +88,7 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
|
|||
const toggleButton = addonDetails.localAddon !== null ?
|
||||
{
|
||||
className: styles['uninstall-button'],
|
||||
label: 'Uninstall',
|
||||
label: t('ADDON_UNINSTALL'),
|
||||
props: {
|
||||
onClick: (event) => {
|
||||
core.transport.dispatch({
|
||||
|
|
@ -113,7 +115,7 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
|
|||
{
|
||||
|
||||
className: styles['install-button'],
|
||||
label: 'Install',
|
||||
label: t('ADDON_INSTALL'),
|
||||
props: {
|
||||
onClick: (event) => {
|
||||
core.transport.dispatch({
|
||||
|
|
@ -141,21 +143,21 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
|
|||
return addonDetails.remoteAddon?.content.type === 'Ready' ? addonDetails.remoteAddon.content.content.manifest.background : null;
|
||||
}, [addonDetails.remoteAddon]);
|
||||
return (
|
||||
<ModalDialog className={styles['addon-details-modal-container']} title={'Stremio addon'} buttons={modalButtons} background={modalBackground} onCloseRequest={onCloseRequest}>
|
||||
<ModalDialog className={styles['addon-details-modal-container']} title={t('STREMIO_COMMUNITY_ADDON')} buttons={modalButtons} background={modalBackground} onCloseRequest={onCloseRequest}>
|
||||
{
|
||||
addonDetails.selected === null ?
|
||||
<div className={styles['addon-details-message-container']}>
|
||||
Loading addon manifest
|
||||
{t('ADDON_LOADING_MANIFEST')}
|
||||
</div>
|
||||
:
|
||||
addonDetails.remoteAddon === null || addonDetails.remoteAddon.content.type === 'Loading' ?
|
||||
<div className={styles['addon-details-message-container']}>
|
||||
Loading addon manifest from {addonDetails.selected.transportUrl}
|
||||
{t('ADDON_LOADING_MANIFEST_FROM', { origin: addonDetails.selected.transportUrl})}
|
||||
</div>
|
||||
:
|
||||
addonDetails.remoteAddon.content.type === 'Err' && addonDetails.localAddon === null ?
|
||||
<div className={styles['addon-details-message-container']}>
|
||||
Failed to get addon manifest from {addonDetails.selected.transportUrl}
|
||||
{t('ADDON_LOADING_MANIFEST_FAILED', {origin: addonDetails.selected.transportUrl})}
|
||||
<div>{addonDetails.remoteAddon.content.content.message}</div>
|
||||
</div>
|
||||
:
|
||||
|
|
@ -174,17 +176,18 @@ AddonDetailsModal.propTypes = {
|
|||
onCloseRequest: PropTypes.func
|
||||
};
|
||||
|
||||
const AddonDetailsModalFallback = ({ onCloseRequest }) => (
|
||||
<ModalDialog
|
||||
const AddonDetailsModalFallback = ({ onCloseRequest }) => {
|
||||
const { t } = useTranslation();
|
||||
return <ModalDialog
|
||||
className={styles['addon-details-modal-container']}
|
||||
title={'Stremio addon'}
|
||||
title={t('STREMIO_COMMUNITY_ADDON')}
|
||||
onCloseRequest={onCloseRequest}
|
||||
>
|
||||
<div className={styles['addon-details-message-container']}>
|
||||
Loading addon manifest
|
||||
{t('ADDON_LOADING_MANIFEST')}
|
||||
</div>
|
||||
</ModalDialog>
|
||||
);
|
||||
</ModalDialog>;
|
||||
};
|
||||
|
||||
AddonDetailsModalFallback.propTypes = AddonDetailsModal.propTypes;
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: @small) and (orientation: portait) {
|
||||
@media only screen and (min-width: @small) and (orientation: portrait) {
|
||||
.bottom-sheet {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import styles from './Button.less';
|
|||
|
||||
type Props = {
|
||||
className?: string,
|
||||
style?: object,
|
||||
href?: string,
|
||||
target?: string
|
||||
title?: string,
|
||||
|
|
@ -15,6 +16,8 @@ type Props = {
|
|||
children: React.ReactNode,
|
||||
onKeyDown?: (event: React.KeyboardEvent) => void,
|
||||
onMouseDown?: (event: React.MouseEvent) => void,
|
||||
onMouseUp?: (event: React.MouseEvent) => void,
|
||||
onMouseLeave?: (event: React.MouseEvent) => void,
|
||||
onLongPress?: () => void,
|
||||
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void,
|
||||
onDoubleClick?: () => void,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
.link {
|
||||
font-size: 0.9rem;
|
||||
color: var(--primary-accent-color);
|
||||
margin-left: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
|
|
@ -69,7 +70,7 @@
|
|||
}
|
||||
|
||||
&.error {
|
||||
border-color: var(--color-trakt);
|
||||
border-color: var(--danger-accent-color);
|
||||
}
|
||||
|
||||
&.checked {
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ const Checkbox = React.forwardRef<HTMLInputElement, Props>(({ name, disabled, cl
|
|||
</div>
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
{' '}
|
||||
{
|
||||
href && link ?
|
||||
<Button className={styles['link']} href={href} target={'_blank'} tabIndex={-1}>
|
||||
|
|
|
|||
|
|
@ -1,75 +1,85 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const AColorPicker = require('a-color-picker');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const { Button } = require('stremio/components');
|
||||
const ModalDialog = require('stremio/components/ModalDialog');
|
||||
const useBinaryState = require('stremio/common/useBinaryState');
|
||||
const ColorPicker = require('./ColorPicker');
|
||||
const styles = require('./styles');
|
||||
import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import * as AColorPicker from 'a-color-picker';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'stremio/components';
|
||||
import ModalDialog from 'stremio/components/ModalDialog';
|
||||
import useBinaryState from 'stremio/common/useBinaryState';
|
||||
import ColorPicker from './ColorPicker';
|
||||
import styles from './ColorInput.less';
|
||||
|
||||
const parseColor = (value) => {
|
||||
const parseColor = (value: string) => {
|
||||
const color = AColorPicker.parseColor(value, 'hexcss4');
|
||||
return typeof color === 'string' ? color : '#ffffffff';
|
||||
};
|
||||
|
||||
const ColorInput = ({ className, value, dataset, onChange, ...props }) => {
|
||||
type Props = {
|
||||
className: string,
|
||||
value: string,
|
||||
onChange?: (value: string) => void,
|
||||
onClick?: (event: React.MouseEvent) => void,
|
||||
};
|
||||
|
||||
const ColorInput = ({ className, value, onChange, ...props }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [modalOpen, openModal, closeModal] = useBinaryState(false);
|
||||
const [tempValue, setTempValue] = React.useState(() => {
|
||||
const [tempValue, setTempValue] = useState(() => {
|
||||
return parseColor(value);
|
||||
});
|
||||
const labelButtonStyle = React.useMemo(() => ({
|
||||
|
||||
const labelButtonStyle = useMemo(() => ({
|
||||
backgroundColor: value
|
||||
}), [value]);
|
||||
const isTransparent = React.useMemo(() => {
|
||||
|
||||
const isTransparent = useMemo(() => {
|
||||
return parseColor(value).endsWith('00');
|
||||
}, [value]);
|
||||
const labelButtonOnClick = React.useCallback((event) => {
|
||||
|
||||
const labelButtonOnClick = useCallback((event: React.MouseEvent) => {
|
||||
if (typeof props.onClick === 'function') {
|
||||
props.onClick(event);
|
||||
}
|
||||
|
||||
// @ts-expect-error: Property 'openModalPrevented' does not exist on type 'MouseEvent'.
|
||||
if (!event.nativeEvent.openModalPrevented) {
|
||||
openModal();
|
||||
}
|
||||
}, [props.onClick]);
|
||||
const modalDialogOnClick = React.useCallback((event) => {
|
||||
|
||||
const modalDialogOnClick = useCallback((event: React.MouseEvent) => {
|
||||
// @ts-expect-error: Property 'openModalPrevented' does not exist on type 'MouseEvent'.
|
||||
event.nativeEvent.openModalPrevented = true;
|
||||
}, []);
|
||||
const modalButtons = React.useMemo(() => {
|
||||
const selectButtonOnClick = (event) => {
|
||||
|
||||
const modalButtons = useMemo(() => {
|
||||
const selectButtonOnClick = () => {
|
||||
if (typeof onChange === 'function') {
|
||||
onChange({
|
||||
type: 'change',
|
||||
value: tempValue,
|
||||
dataset: dataset,
|
||||
reactEvent: event,
|
||||
nativeEvent: event.nativeEvent
|
||||
});
|
||||
onChange(tempValue);
|
||||
}
|
||||
|
||||
closeModal();
|
||||
};
|
||||
return [
|
||||
{
|
||||
label: 'Select',
|
||||
label: t('SELECT'),
|
||||
props: {
|
||||
'data-autofocus': true,
|
||||
onClick: selectButtonOnClick
|
||||
}
|
||||
}
|
||||
];
|
||||
}, [tempValue, dataset, onChange]);
|
||||
const colorPickerOnInput = React.useCallback((event) => {
|
||||
setTempValue(parseColor(event.value));
|
||||
}, [tempValue, onChange]);
|
||||
|
||||
const colorPickerOnInput = useCallback((color: string) => {
|
||||
setTempValue(parseColor(color));
|
||||
}, []);
|
||||
React.useLayoutEffect(() => {
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setTempValue(parseColor(value));
|
||||
}, [value, modalOpen]);
|
||||
|
||||
return (
|
||||
<Button title={isTransparent ? t('BUTTON_COLOR_TRANSPARENT') : value} {...props} style={labelButtonStyle} className={classnames(className, styles['color-input-container'])} onClick={labelButtonOnClick}>
|
||||
{
|
||||
|
|
@ -82,7 +92,7 @@ const ColorInput = ({ className, value, dataset, onChange, ...props }) => {
|
|||
}
|
||||
{
|
||||
modalOpen ?
|
||||
<ModalDialog title={'Choose a color:'} buttons={modalButtons} onCloseRequest={closeModal} onClick={modalDialogOnClick}>
|
||||
<ModalDialog title={t('CHOOSE_COLOR')} buttons={modalButtons} onCloseRequest={closeModal} onClick={modalDialogOnClick}>
|
||||
<ColorPicker className={styles['color-picker-container']} value={tempValue} onInput={colorPickerOnInput} />
|
||||
</ModalDialog>
|
||||
:
|
||||
|
|
@ -92,12 +102,4 @@ const ColorInput = ({ className, value, dataset, onChange, ...props }) => {
|
|||
);
|
||||
};
|
||||
|
||||
ColorInput.propTypes = {
|
||||
className: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
dataset: PropTypes.object,
|
||||
onChange: PropTypes.func,
|
||||
onClick: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = ColorInput;
|
||||
export default ColorInput;
|
||||
|
|
@ -21,7 +21,7 @@ const ColorPicker = ({ className, value, onInput }) => {
|
|||
showRGB: false,
|
||||
showAlpha: true
|
||||
});
|
||||
const pickerClipboard = pickerElementRef.current.querySelector('.a-color-picker-clipbaord');
|
||||
const pickerClipboard = pickerElementRef.current.querySelector('.a-color-picker-clipboard');
|
||||
if (pickerClipboard instanceof HTMLElement) {
|
||||
pickerClipboard.tabIndex = -1;
|
||||
}
|
||||
|
|
@ -29,10 +29,7 @@ const ColorPicker = ({ className, value, onInput }) => {
|
|||
React.useLayoutEffect(() => {
|
||||
if (typeof onInput === 'function') {
|
||||
pickerRef.current.on('change', (picker, value) => {
|
||||
onInput({
|
||||
type: 'input',
|
||||
value: parseColor(value)
|
||||
});
|
||||
onInput(parseColor(value));
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
box-shadow: 0 0 .2rem var(--color-surfacedark);
|
||||
}
|
||||
|
||||
:global(.a-color-picker-clipbaord) {
|
||||
:global(.a-color-picker-clipboard) {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const ColorInput = require('./ColorInput');
|
||||
|
||||
module.exports = ColorInput;
|
||||
|
||||
6
src/components/ColorInput/index.ts
Normal file
6
src/components/ColorInput/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
import ColorInput from './ColorInput';
|
||||
|
||||
export default ColorInput;
|
||||
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const ContineWatchingItem = require('./ContinueWatchingItem');
|
||||
const ContinueWatchingItem = require('./ContinueWatchingItem');
|
||||
|
||||
module.exports = ContineWatchingItem;
|
||||
module.exports = ContinueWatchingItem;
|
||||
|
|
|
|||
|
|
@ -65,8 +65,16 @@
|
|||
padding: 0 1rem;
|
||||
|
||||
.icon-container {
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
|
||||
.icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.label-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,18 +3,18 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const { Button } = require('stremio/components');
|
||||
const useTranslate = require('stremio/common/useTranslate');
|
||||
const styles = require('./styles');
|
||||
|
||||
const MetaLinks = ({ className, label, links }) => {
|
||||
const { t } = useTranslation();
|
||||
const { string, stringWithPrefix } = useTranslate();
|
||||
return (
|
||||
<div className={classnames(className, styles['meta-links-container'])}>
|
||||
{
|
||||
typeof label === 'string' && label.length > 0 ?
|
||||
<div className={styles['label-container']}>
|
||||
{t(`LINKS_${label.toUpperCase()}`)}
|
||||
{ stringWithPrefix(label.toUpperCase(), 'LINKS') }
|
||||
</div>
|
||||
:
|
||||
null
|
||||
|
|
@ -24,7 +24,7 @@ const MetaLinks = ({ className, label, links }) => {
|
|||
<div className={styles['links-container']}>
|
||||
{links.map(({ label, href }, index) => (
|
||||
<Button key={index} className={styles['link-container']} title={label} href={href}>
|
||||
{ t(label) }
|
||||
{ string(label) }
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const ActionButton = require('./ActionButton');
|
|||
const MetaLinks = require('./MetaLinks');
|
||||
const MetaPreviewPlaceholder = require('./MetaPreviewPlaceholder');
|
||||
const styles = require('./styles');
|
||||
const { Ratings } = require('./Ratings');
|
||||
|
||||
const ALLOWED_LINK_REDIRECTS = [
|
||||
routesRegexp.search.regexp,
|
||||
|
|
@ -24,7 +25,7 @@ const ALLOWED_LINK_REDIRECTS = [
|
|||
routesRegexp.metadetails.regexp
|
||||
];
|
||||
|
||||
const MetaPreview = ({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary }) => {
|
||||
const MetaPreview = React.forwardRef(({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary, ratingInfo }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [shareModalOpen, openShareModal, closeShareModal] = useBinaryState(false);
|
||||
const linksGroups = React.useMemo(() => {
|
||||
|
|
@ -98,7 +99,7 @@ const MetaPreview = ({ className, compact, name, logo, background, runtime, rele
|
|||
<div className={styles['logo-placeholder']}>{name}</div>
|
||||
), [name]);
|
||||
return (
|
||||
<div className={classnames(className, styles['meta-preview-container'], { [styles['compact']]: compact })}>
|
||||
<div className={classnames(className, styles['meta-preview-container'], { [styles['compact']]: compact })} ref={ref}>
|
||||
{
|
||||
typeof background === 'string' && background.length > 0 ?
|
||||
<div className={styles['background-image-layer']}>
|
||||
|
|
@ -232,6 +233,15 @@ const MetaPreview = ({ className, compact, name, logo, background, runtime, rele
|
|||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
!compact && ratingInfo !== null ?
|
||||
<Ratings
|
||||
ratingInfo={ratingInfo}
|
||||
className={styles['ratings']}
|
||||
/>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
linksGroups.has(CONSTANTS.SHARE_LINK_CATEGORY) && !compact ?
|
||||
<React.Fragment>
|
||||
|
|
@ -261,7 +271,7 @@ const MetaPreview = ({ className, compact, name, logo, background, runtime, rele
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
MetaPreview.Placeholder = MetaPreviewPlaceholder;
|
||||
|
||||
|
|
@ -287,7 +297,8 @@ MetaPreview.propTypes = {
|
|||
})),
|
||||
trailerStreams: PropTypes.array,
|
||||
inLibrary: PropTypes.bool,
|
||||
toggleInLibrary: PropTypes.func
|
||||
toggleInLibrary: PropTypes.func,
|
||||
ratingInfo: PropTypes.object,
|
||||
};
|
||||
|
||||
module.exports = MetaPreview;
|
||||
|
|
|
|||
62
src/components/MetaPreview/Ratings/Ratings.less
Normal file
62
src/components/MetaPreview/Ratings/Ratings.less
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
@import (reference) '~stremio/common/screen-sizes.less';
|
||||
|
||||
@height: 4rem;
|
||||
@width: 4rem;
|
||||
@height-mobile: 3rem;
|
||||
@width-mobile: 3rem;
|
||||
|
||||
|
||||
.ratings-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
background-color: var(--overlay-color);
|
||||
border-radius: 2rem;
|
||||
height: @height;
|
||||
width: fit-content;
|
||||
backdrop-filter: blur(5px);
|
||||
|
||||
.icon-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: @height;
|
||||
width: @width;
|
||||
padding: 0 1rem;
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
width: calc(@width / 2);
|
||||
height: calc(@height / 2);
|
||||
color: var(--primary-foreground-color);
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media @phone-landscape {
|
||||
.ratings-container {
|
||||
height: @height-mobile;
|
||||
|
||||
.icon-container {
|
||||
height: @height-mobile;
|
||||
width: @width-mobile;
|
||||
|
||||
.icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/components/MetaPreview/Ratings/Ratings.tsx
Normal file
31
src/components/MetaPreview/Ratings/Ratings.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import useRating from './useRating';
|
||||
import styles from './Ratings.less';
|
||||
import Icon from '@stremio/stremio-icons/react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
type Props = {
|
||||
metaId?: string;
|
||||
ratingInfo?: Loadable<RatingInfo>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const Ratings = ({ ratingInfo, className }: Props) => {
|
||||
const { onLiked, onLoved, liked, loved } = useRating(ratingInfo);
|
||||
const disabled = useMemo(() => ratingInfo?.type !== 'Ready', [ratingInfo]);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles['ratings-container'], className)}>
|
||||
<div className={classNames(styles['icon-container'], { [styles['disabled']]: disabled })} onClick={onLiked}>
|
||||
<Icon name={liked ? 'thumbs-up' : 'thumbs-up-outline'} className={styles['icon']} />
|
||||
</div>
|
||||
<div className={classNames(styles['icon-container'], { [styles['disabled']]: disabled })} onClick={onLoved}>
|
||||
<Icon name={loved ? 'heart' : 'heart-outline'} className={styles['icon']} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Ratings;
|
||||
5
src/components/MetaPreview/Ratings/index.ts
Normal file
5
src/components/MetaPreview/Ratings/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import Ratings from './Ratings';
|
||||
|
||||
export { Ratings };
|
||||
48
src/components/MetaPreview/Ratings/useRating.ts
Normal file
48
src/components/MetaPreview/Ratings/useRating.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useServices } from 'stremio/services';
|
||||
|
||||
const useRating = (ratingInfo?: Loadable<RatingInfo>) => {
|
||||
const { core } = useServices();
|
||||
|
||||
const setRating = useCallback((status: Rating) => {
|
||||
core.transport.dispatch({
|
||||
action: 'MetaDetails',
|
||||
args: {
|
||||
action: 'Rate',
|
||||
args: status,
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
const status = useMemo(() => {
|
||||
const content = ratingInfo?.type === 'Ready' ? ratingInfo.content as RatingInfo : null;
|
||||
return content?.status;
|
||||
}, [ratingInfo]);
|
||||
|
||||
const liked = useMemo(() => {
|
||||
return status === 'liked';
|
||||
}, [status]);
|
||||
|
||||
const loved = useMemo(() => {
|
||||
return status === 'loved';
|
||||
}, [status]);
|
||||
|
||||
const onLiked = useCallback(() => {
|
||||
setRating(status === 'liked' ? null : 'liked');
|
||||
}, [status]);
|
||||
|
||||
const onLoved = useCallback(() => {
|
||||
setRating(status === 'loved' ? null : 'loved');
|
||||
}, [status]);
|
||||
|
||||
return {
|
||||
onLiked,
|
||||
onLoved,
|
||||
liked,
|
||||
loved,
|
||||
};
|
||||
};
|
||||
|
||||
export default useRating;
|
||||
|
|
@ -159,7 +159,6 @@
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
max-height: 15rem;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 3.5rem;
|
||||
overflow: visible;
|
||||
|
|
@ -209,6 +208,11 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ratings {
|
||||
margin-bottom: 1rem;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.share-prompt {
|
||||
|
|
@ -236,6 +240,10 @@
|
|||
border-radius: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ratings {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { useRouteFocused, useModalsContainer } = require('stremio-router');
|
||||
|
|
@ -10,6 +11,7 @@ const { Modal } = require('stremio-router');
|
|||
const styles = require('./styles');
|
||||
|
||||
const ModalDialog = ({ className, title, buttons, children, dataset, onCloseRequest, background, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
const routeFocused = useRouteFocused();
|
||||
const modalsContainer = useModalsContainer();
|
||||
const modalContainerRef = React.useRef(null);
|
||||
|
|
@ -60,7 +62,7 @@ const ModalDialog = ({ className, title, buttons, children, dataset, onCloseRequ
|
|||
<Modal ref={modalContainerRef} {...props} className={classnames(className, styles['modal-container'])} onMouseDown={onModalContainerMouseDown}>
|
||||
<div className={styles['modal-dialog-container']} onMouseDown={onModalDialogContainerMouseDown}>
|
||||
<div className={styles['modal-dialog-background']} style={{backgroundImage: `url('${background}')`}} />
|
||||
<Button className={styles['close-button-container']} title={'Close'} onClick={closeButtonOnClick}>
|
||||
<Button className={styles['close-button-container']} title={t('BUTTON_CLOSE')} onClick={closeButtonOnClick}>
|
||||
<Icon className={styles['icon']} name={'close'} />
|
||||
</Button>
|
||||
<div className={styles['modal-dialog-content']}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
|
|
@ -10,16 +11,17 @@ const ModalDialog = require('stremio/components/ModalDialog');
|
|||
const useBinaryState = require('stremio/common/useBinaryState');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Multiselect = ({ className, mode, direction, title, disabled, dataset, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => {
|
||||
const Multiselect = ({ className, mode, direction, title, disabled, dataset, options, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
|
||||
const options = React.useMemo(() => {
|
||||
return Array.isArray(props.options) ?
|
||||
props.options.filter((option) => {
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
return Array.isArray(options) ?
|
||||
options.filter((option) => {
|
||||
return option && (typeof option.value === 'string' || option.value === null);
|
||||
})
|
||||
:
|
||||
[];
|
||||
}, [props.options]);
|
||||
}, [options]);
|
||||
const selected = React.useMemo(() => {
|
||||
return Array.isArray(props.selected) ?
|
||||
props.selected.filter((value) => {
|
||||
|
|
@ -94,7 +96,7 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, ren
|
|||
:
|
||||
selected.length > 0 ?
|
||||
selected.map((value) => {
|
||||
const option = options.find((option) => option.value === value);
|
||||
const option = filteredOptions.find((option) => option.value === value);
|
||||
return option && typeof option.label === 'string' ?
|
||||
option.label
|
||||
:
|
||||
|
|
@ -109,12 +111,12 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, ren
|
|||
}
|
||||
{children}
|
||||
</Button>
|
||||
), [menuOpen, title, disabled, options, selected, labelOnClick, renderLabelContent, renderLabelText]);
|
||||
), [menuOpen, title, disabled, filteredOptions, selected, labelOnClick, renderLabelContent, renderLabelText]);
|
||||
const renderMenu = React.useCallback(() => (
|
||||
<div className={styles['menu-container']} onKeyDown={menuOnKeyDown} onClick={menuOnClick}>
|
||||
{
|
||||
options.length > 0 ?
|
||||
options.map(({ label, title, value }) => (
|
||||
filteredOptions.length > 0 ?
|
||||
filteredOptions.map(({ label, title, value }) => (
|
||||
<Button key={value} className={classnames(styles['option-container'], { 'selected': selected.includes(value) })} title={typeof title === 'string' ? title : typeof label === 'string' ? label : value} data-value={value} onClick={optionOnClick}>
|
||||
<div className={styles['label']}>{typeof label === 'string' ? label : value}</div>
|
||||
<div className={styles['icon']} />
|
||||
|
|
@ -122,11 +124,11 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, ren
|
|||
))
|
||||
:
|
||||
<div className={styles['no-options-container']}>
|
||||
<div className={styles['label']}>No options available</div>
|
||||
<div className={styles['label']}>{t('NO_OPTIONS')}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
), [options, selected, menuOnKeyDown, menuOnClick, optionOnClick]);
|
||||
), [filteredOptions, selected, menuOnKeyDown, menuOnClick, optionOnClick]);
|
||||
const renderPopupLabel = React.useMemo(() => (labelProps) => {
|
||||
return renderLabel({
|
||||
...labelProps,
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
|
||||
.modal-container, .popup-menu-container {
|
||||
.menu-container {
|
||||
max-height: calc(3.2rem * 7);
|
||||
max-height: calc(3rem * 7);
|
||||
|
||||
.option-container {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
@import (reference) '~stremio/common/screen-sizes.less';
|
||||
|
||||
@parent-height: 10rem;
|
||||
@parent-height: 12rem;
|
||||
@item-height: 3rem;
|
||||
|
||||
.dropdown {
|
||||
background: var(--modal-background-color);
|
||||
|
|
@ -18,7 +19,7 @@
|
|||
|
||||
&.open {
|
||||
display: block;
|
||||
max-height: calc(3.3rem * 7);
|
||||
max-height: calc(@item-height * 7);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,23 +10,25 @@ import styles from './Dropdown.less';
|
|||
|
||||
type Props = {
|
||||
options: MultiselectMenuOption[];
|
||||
selectedOption?: MultiselectMenuOption | null;
|
||||
value?: any;
|
||||
menuOpen: boolean | (() => void);
|
||||
level: number;
|
||||
setLevel: (level: number) => void;
|
||||
onSelect: (value: number) => void;
|
||||
onSelect: (value: any) => void;
|
||||
};
|
||||
|
||||
const Dropdown = ({ level, setLevel, options, onSelect, selectedOption, menuOpen }: Props) => {
|
||||
const Dropdown = ({ level, setLevel, options, onSelect, value, menuOpen }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const optionsRef = useRef(new Map());
|
||||
const containerRef = useRef(null);
|
||||
|
||||
const handleSetOptionRef = useCallback((value: number) => (node: HTMLButtonElement | null) => {
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
|
||||
const handleSetOptionRef = useCallback((optionValue: any) => (node: HTMLButtonElement | null) => {
|
||||
if (node) {
|
||||
optionsRef.current.set(value, node);
|
||||
optionsRef.current.set(optionValue, node);
|
||||
} else {
|
||||
optionsRef.current.delete(value);
|
||||
optionsRef.current.delete(optionValue);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -63,11 +65,11 @@ const Dropdown = ({ level, setLevel, options, onSelect, selectedOption, menuOpen
|
|||
.filter((option: MultiselectMenuOption) => !option.hidden)
|
||||
.map((option: MultiselectMenuOption) => (
|
||||
<Option
|
||||
key={option.id}
|
||||
key={option.value}
|
||||
ref={handleSetOptionRef(option.value)}
|
||||
option={option}
|
||||
onSelect={onSelect}
|
||||
selectedOption={selectedOption}
|
||||
selectedValue={value}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
@height: 3rem;
|
||||
|
||||
.option {
|
||||
height: @height;
|
||||
font-size: var(--font-size-normal);
|
||||
color: var(--primary-foreground-color);
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -8,13 +8,12 @@ import Icon from '@stremio/stremio-icons/react';
|
|||
|
||||
type Props = {
|
||||
option: MultiselectMenuOption;
|
||||
selectedOption?: MultiselectMenuOption | null;
|
||||
onSelect: (value: number) => void;
|
||||
selectedValue?: any;
|
||||
onSelect: (value: any) => void;
|
||||
};
|
||||
|
||||
const Option = forwardRef<HTMLButtonElement, Props>(({ option, selectedOption, onSelect }, ref) => {
|
||||
// consider using option.id === selectedOption?.id instead
|
||||
const selected = useMemo(() => option?.value === selectedOption?.value, [option, selectedOption]);
|
||||
const Option = forwardRef<HTMLButtonElement, Props>(({ option, selectedValue, onSelect }, ref) => {
|
||||
const selected = useMemo(() => option?.value === selectedValue, [option, selectedValue]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect(option.value);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
@border-radius: 2.75rem;
|
||||
@height: 3rem;
|
||||
|
||||
.multiselect-menu {
|
||||
position: relative;
|
||||
|
|
@ -14,14 +15,22 @@
|
|||
}
|
||||
|
||||
.multiselect-button {
|
||||
color: var(--primary-foreground-color);
|
||||
height: @height;
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0 0.5rem;
|
||||
border-radius: @border-radius;
|
||||
|
||||
.label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 1rem;
|
||||
color: var(--primary-foreground-color);
|
||||
|
|
@ -33,7 +42,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:hover, &.active {
|
||||
background-color: var(--overlay-color);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,31 +11,41 @@ import useOutsideClick from 'stremio/common/useOutsideClick';
|
|||
|
||||
type Props = {
|
||||
className?: string,
|
||||
title?: string;
|
||||
title?: string | (() => string | null);
|
||||
options: MultiselectMenuOption[];
|
||||
selectedOption?: MultiselectMenuOption;
|
||||
onSelect: (value: number) => void;
|
||||
value?: any;
|
||||
disabled?: boolean,
|
||||
onSelect: (value: any) => void;
|
||||
};
|
||||
|
||||
const MultiselectMenu = ({ className, title, options, selectedOption, onSelect }: Props) => {
|
||||
const MultiselectMenu = ({ className, title, options, value, disabled, onSelect }: Props) => {
|
||||
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
|
||||
const multiselectMenuRef = useOutsideClick(() => closeMenu());
|
||||
const [level, setLevel] = React.useState<number>(0);
|
||||
|
||||
const onOptionSelect = (value: number) => {
|
||||
level ? setLevel(level + 1) : onSelect(value), closeMenu();
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
|
||||
const onOptionSelect = (selectedValue: string | number) => {
|
||||
level ? setLevel(level + 1) : onSelect(selectedValue), closeMenu();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames(styles['multiselect-menu'], className)} ref={multiselectMenuRef}>
|
||||
<div className={classNames(styles['multiselect-menu'], { [styles['active']]: menuOpen }, className)} ref={multiselectMenuRef}>
|
||||
<Button
|
||||
className={classNames(styles['multiselect-button'], { [styles['open']]: menuOpen })}
|
||||
disabled={disabled}
|
||||
onClick={toggleMenu}
|
||||
tabIndex={0}
|
||||
aria-haspopup='listbox'
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
{title}
|
||||
<div className={styles['label']}>
|
||||
{
|
||||
typeof title === 'function'
|
||||
? title()
|
||||
: title ?? selectedOption?.label
|
||||
}
|
||||
</div>
|
||||
<Icon name={'caret-down'} className={classNames(styles['icon'], { [styles['open']]: menuOpen })} />
|
||||
</Button>
|
||||
{
|
||||
|
|
@ -46,7 +56,7 @@ const MultiselectMenu = ({ className, title, options, selectedOption, onSelect }
|
|||
options={options}
|
||||
onSelect={onOptionSelect}
|
||||
menuOpen={menuOpen}
|
||||
selectedOption={selectedOption}
|
||||
value={value}
|
||||
/>
|
||||
: null
|
||||
}
|
||||
|
|
|
|||
2
src/components/MultiselectMenu/types.d.ts
vendored
2
src/components/MultiselectMenu/types.d.ts
vendored
|
|
@ -1,7 +1,7 @@
|
|||
type MultiselectMenuOption = {
|
||||
id?: number;
|
||||
label: string;
|
||||
value: number;
|
||||
value: string | number | null;
|
||||
destination?: string;
|
||||
default?: boolean;
|
||||
hidden?: boolean;
|
||||
|
|
|
|||
65
src/components/NumberInput/NumberInput.less
Normal file
65
src/components/NumberInput/NumberInput.less
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
.number-input {
|
||||
user-select: text;
|
||||
display: flex;
|
||||
max-width: 14rem;
|
||||
height: 3.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--primary-foreground-color);
|
||||
background: var(--overlay-color);
|
||||
border-radius: 3.5rem;
|
||||
|
||||
.button {
|
||||
flex: none;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--overlay-color);
|
||||
border: none;
|
||||
border-radius: 100%;
|
||||
cursor: pointer;
|
||||
z-index: 1;
|
||||
|
||||
.icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.number-display {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 0 1rem;
|
||||
|
||||
&::-moz-focus-inner {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
color: var(--primary-foreground-color);
|
||||
text-align: center;
|
||||
appearance: none;
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
src/components/NumberInput/NumberInput.tsx
Normal file
113
src/components/NumberInput/NumberInput.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import Icon from '@stremio/stremio-icons/react';
|
||||
import React, { ChangeEvent, forwardRef, memo, useCallback, useState } from 'react';
|
||||
import { type KeyboardEvent, type InputHTMLAttributes } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import styles from './NumberInput.less';
|
||||
import Button from '../Button';
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
containerClassName?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
showButtons?: boolean;
|
||||
defaultValue?: number;
|
||||
label?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
value?: number;
|
||||
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
onSubmit?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
const NumberInput = forwardRef<HTMLInputElement, Props>(({ defaultValue = 0, showButtons, onKeyDown, onSubmit, min, max, onChange, ...props }, ref) => {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const displayValue = props.value ?? value;
|
||||
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
|
||||
onKeyDown?.(event);
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
onSubmit?.(event);
|
||||
}
|
||||
}, [onKeyDown, onSubmit]);
|
||||
|
||||
const handleValueChange = (newValue: number) => {
|
||||
if (props.value === undefined) {
|
||||
setValue(newValue);
|
||||
}
|
||||
onChange?.({ target: { value: newValue.toString() }} as ChangeEvent<HTMLInputElement>);
|
||||
};
|
||||
|
||||
const handleIncrement = () => {
|
||||
handleValueChange(clampValueToRange((displayValue || 0) + 1));
|
||||
};
|
||||
|
||||
const handleDecrement = () => {
|
||||
handleValueChange(clampValueToRange((displayValue || 0) - 1));
|
||||
};
|
||||
|
||||
const clampValueToRange = (value: number): number => {
|
||||
const minValue = min ?? 0;
|
||||
|
||||
if (value < minValue) {
|
||||
return minValue;
|
||||
}
|
||||
|
||||
if (max !== undefined && value > max) {
|
||||
return max;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const handleInputChange = useCallback(({ target: { valueAsNumber }}: ChangeEvent<HTMLInputElement>) => {
|
||||
handleValueChange(clampValueToRange(valueAsNumber || 0));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={classnames(props.containerClassName, styles['number-input'])}>
|
||||
{
|
||||
showButtons ?
|
||||
<Button
|
||||
className={styles['button']}
|
||||
onClick={handleDecrement}
|
||||
disabled={props.disabled || (min !== undefined ? displayValue <= min : false)}>
|
||||
<Icon className={styles['icon']} name={'remove'} />
|
||||
</Button>
|
||||
: null
|
||||
}
|
||||
<div className={classnames(styles['number-display'], { [styles['buttons-container']]: showButtons })}>
|
||||
{
|
||||
props.label ?
|
||||
<div className={styles['label']}>{props.label}</div>
|
||||
: null
|
||||
}
|
||||
<input
|
||||
ref={ref}
|
||||
type={'number'}
|
||||
tabIndex={0}
|
||||
value={displayValue}
|
||||
{...props}
|
||||
className={classnames(props.className, styles['value'], { [styles.disabled]: props.disabled })}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
showButtons ?
|
||||
<Button
|
||||
className={styles['button']} onClick={handleIncrement} disabled={props.disabled || (max !== undefined ? displayValue >= max : false)}>
|
||||
<Icon className={styles['icon']} name={'add'} />
|
||||
</Button>
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
NumberInput.displayName = 'NumberInput';
|
||||
|
||||
export default memo(NumberInput);
|
||||
5
src/components/NumberInput/index.ts
Normal file
5
src/components/NumberInput/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import NumberInput from './NumberInput';
|
||||
|
||||
export default NumberInput;
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
}
|
||||
|
||||
&.error {
|
||||
border-color: var(--color-trakt);
|
||||
border-color: var(--danger-accent-color);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ const SharePrompt = ({ className, url }) => {
|
|||
onClick={selectInputContent}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<Button className={styles['copy-button']} title={'Copy to clipboard'} onClick={copyToClipboard}>
|
||||
<Button className={styles['copy-button']} title={t('CTX_COPY_TO_CLIPBOARD')} onClick={copyToClipboard}>
|
||||
<Icon className={styles['icon']} name={'link'} />
|
||||
<div className={styles['label']}>{ t('COPY') }</div>
|
||||
</Button>
|
||||
|
|
|
|||
22
src/components/ShortcutsGroup/Combos/Combos.less
Normal file
22
src/components/ShortcutsGroup/Combos/Combos.less
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
.combos {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: visible;
|
||||
|
||||
.combo {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: visible;
|
||||
|
||||
.separator {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
font-size: 1rem;
|
||||
color: var(--primary-foreground-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/components/ShortcutsGroup/Combos/Combos.tsx
Normal file
33
src/components/ShortcutsGroup/Combos/Combos.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Keys from './Keys';
|
||||
import styles from './Combos.less';
|
||||
|
||||
type Props = {
|
||||
combos: string[][],
|
||||
};
|
||||
|
||||
const Combos = ({ combos }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={styles['combos']}>
|
||||
{
|
||||
combos.map((keys, index) => (
|
||||
<div className={styles['combo']} key={index}>
|
||||
<Keys keys={keys} />
|
||||
{
|
||||
index < (combos.length - 1) && (
|
||||
<div className={styles['separator']}>
|
||||
{ t('SETTINGS_SHORTCUT_OR') }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Combos;
|
||||
26
src/components/ShortcutsGroup/Combos/Keys/Keys.less
Normal file
26
src/components/ShortcutsGroup/Combos/Keys/Keys.less
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
kbd {
|
||||
flex: none;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 2.5rem;
|
||||
min-width: 2.5rem;
|
||||
padding: 0 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--primary-foreground-color);
|
||||
border-radius: 0.25em;
|
||||
box-shadow: 0 4px 0 1px rgba(255, 255, 255, 0.1);
|
||||
background-color: var(--overlay-color);
|
||||
}
|
||||
|
||||
.separator {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
font-size: 1rem;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
51
src/components/ShortcutsGroup/Combos/Keys/Keys.tsx
Normal file
51
src/components/ShortcutsGroup/Combos/Keys/Keys.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import React, { Fragment, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styles from './Keys.less';
|
||||
|
||||
type Props = {
|
||||
keys: string[],
|
||||
};
|
||||
|
||||
const Keys = ({ keys }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const keyLabelMap: Record<string, string> = useMemo(() => ({
|
||||
'Shift': `⇧ ${t('SETTINGS_SHORTCUT_SHIFT')}`,
|
||||
'Space': t('SETTINGS_SHORTCUT_SPACE'),
|
||||
'Ctrl': t('SETTINGS_SHORTCUT_CTRL'),
|
||||
'Escape': t('SETTINGS_SHORTCUT_ESC'),
|
||||
'ArrowUp': '↑',
|
||||
'ArrowDown': '↓',
|
||||
'ArrowLeft': '←',
|
||||
'ArrowRight': '→',
|
||||
}), [t]);
|
||||
|
||||
const isRange = useMemo(() => {
|
||||
return keys.length > 1 && keys.every((key) => !Number.isNaN(parseInt(key)));
|
||||
}, [keys]);
|
||||
|
||||
const filteredKeys = useMemo(() => {
|
||||
return isRange ? [keys[0], keys[keys.length - 1]] : keys;
|
||||
}, [keys, isRange]);
|
||||
|
||||
return (
|
||||
filteredKeys.map((key, index) => (
|
||||
<Fragment key={key}>
|
||||
<kbd>
|
||||
{keyLabelMap[key] ?? key.toUpperCase()}
|
||||
</kbd>
|
||||
{
|
||||
index < (filteredKeys.length - 1) && (
|
||||
<div className={styles['separator']}>
|
||||
{
|
||||
isRange ? t('SETTINGS_SHORTCUT_TO') : '+'
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Fragment>
|
||||
))
|
||||
);
|
||||
};
|
||||
|
||||
export default Keys;
|
||||
2
src/components/ShortcutsGroup/Combos/Keys/index.ts
Normal file
2
src/components/ShortcutsGroup/Combos/Keys/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import Keys from './Keys';
|
||||
export default Keys;
|
||||
2
src/components/ShortcutsGroup/Combos/index.ts
Normal file
2
src/components/ShortcutsGroup/Combos/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import Combos from './Combos';
|
||||
export default Combos;
|
||||
44
src/components/ShortcutsGroup/ShortcutsGroup.less
Normal file
44
src/components/ShortcutsGroup/ShortcutsGroup.less
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
.shortcuts-group {
|
||||
flex: 1 1 0;
|
||||
position: relative;
|
||||
width: 30rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
overflow: visible;
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
display: flex;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--primary-foreground-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
overflow: visible;
|
||||
|
||||
.shortcut {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
overflow: visible;
|
||||
|
||||
.label {
|
||||
position: relative;
|
||||
font-size: 1rem;
|
||||
color: var(--primary-foreground-color);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/components/ShortcutsGroup/ShortcutsGroup.tsx
Normal file
38
src/components/ShortcutsGroup/ShortcutsGroup.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Combos from './Combos';
|
||||
import styles from './ShortcutsGroup.less';
|
||||
|
||||
type Props = {
|
||||
className?: string,
|
||||
label: string,
|
||||
shortcuts: Shortcut[],
|
||||
};
|
||||
|
||||
const ShortcutsGroup = ({ className, label, shortcuts }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={classNames(className, styles['shortcuts-group'])}>
|
||||
<div className={styles['title']}>
|
||||
{t(label)}
|
||||
</div>
|
||||
|
||||
<div className={styles['shortcuts']}>
|
||||
{
|
||||
shortcuts.map(({ name, label, combos }) => (
|
||||
<div className={styles['shortcut']} key={name}>
|
||||
<div className={styles['label']}>
|
||||
{t(label)}
|
||||
</div>
|
||||
<Combos combos={combos} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutsGroup;
|
||||
2
src/components/ShortcutsGroup/index.ts
Normal file
2
src/components/ShortcutsGroup/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import ShortcutsGroup from './ShortcutsGroup';
|
||||
export default ShortcutsGroup;
|
||||
|
|
@ -142,11 +142,11 @@ const Slider = ({ className, value, buffered, minimumValue, maximumValue, disabl
|
|||
<div className={styles['layer']}>
|
||||
<div
|
||||
className={classnames(styles['track-after'], { [styles['audio-boost']]: audioBoost })}
|
||||
style={{ '--mask-width': `calc(${thumbPosition} * 100%)` }}
|
||||
style={{ '--mask-width': `calc(${thumbPosition.toFixed(3)} * 100%)` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles['layer']}>
|
||||
<div className={styles['thumb']} style={{ marginLeft: `calc(100% * ${thumbPosition})` }} />
|
||||
<div className={styles['thumb']} style={{ marginLeft: `calc(100% * ${thumbPosition.toFixed(3)})` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { Button } = require('stremio/components');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Toggle = React.forwardRef(({ className, checked, children, ...props }, ref) => {
|
||||
return (
|
||||
<Button {...props} ref={ref} className={classnames(className, styles['toggle-container'], { 'checked': checked })}>
|
||||
<div className={styles['toggle']} />
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
Toggle.displayName = 'Toggle';
|
||||
|
||||
Toggle.propTypes = {
|
||||
className: PropTypes.string,
|
||||
checked: PropTypes.bool,
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
module.exports = Toggle;
|
||||
27
src/components/Toggle/Toggle.tsx
Normal file
27
src/components/Toggle/Toggle.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
import React, { forwardRef } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import { Button } from 'stremio/components';
|
||||
import styles from './Toggle.less';
|
||||
|
||||
type Props = {
|
||||
className?: string,
|
||||
checked: boolean,
|
||||
disabled?: boolean,
|
||||
tabIndex?: number,
|
||||
children?: React.ReactNode,
|
||||
};
|
||||
|
||||
const Toggle = forwardRef(({ className, checked, children, ...props }: Props, ref) => {
|
||||
return (
|
||||
<Button {...props} ref={ref} className={classnames(className, styles['toggle-container'], { 'checked': checked })}>
|
||||
<div className={styles['toggle']} />
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
Toggle.displayName = 'Toggle';
|
||||
|
||||
export default Toggle;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const Toggle = require('./Toggle');
|
||||
|
||||
module.exports = Toggle;
|
||||
5
src/components/Toggle/index.ts
Normal file
5
src/components/Toggle/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
import Toggle from './Toggle';
|
||||
|
||||
export default Toggle;
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { t } = require('i18next');
|
||||
const { useRouteFocused } = require('stremio-router');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { Button, Image, Popup } = require('stremio/components');
|
||||
|
|
@ -12,10 +12,12 @@ const useProfile = require('stremio/common/useProfile');
|
|||
const VideoPlaceholder = require('./VideoPlaceholder');
|
||||
const styles = require('./styles');
|
||||
|
||||
const Video = ({ className, id, title, thumbnail, season, episode, released, upcoming, watched, progress, scheduled, seasonWatched, deepLinks, onMarkVideoAsWatched, onMarkSeasonAsWatched, ...props }) => {
|
||||
const Video = ({ className, id, title, thumbnail, season, episode, released, upcoming, watched, progress, scheduled, seasonWatched, selected, deepLinks, onMarkVideoAsWatched, onMarkSeasonAsWatched, ...props }) => {
|
||||
const routeFocused = useRouteFocused();
|
||||
const profile = useProfile();
|
||||
const { t } = useTranslation();
|
||||
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
|
||||
|
||||
const popupLabelOnMouseUp = React.useCallback((event) => {
|
||||
if (!event.nativeEvent.togglePopupPrevented) {
|
||||
if (event.nativeEvent.ctrlKey || event.nativeEvent.button === 2) {
|
||||
|
|
@ -67,10 +69,23 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc
|
|||
}
|
||||
}
|
||||
}, [deepLinks]);
|
||||
const renderLabel = React.useMemo(() => function renderLabel({ className, id, title, thumbnail, episode, released, upcoming, watched, progress, scheduled, children, ...props }) {
|
||||
const renderLabel = React.useMemo(() => function renderLabel({ className, id, title, thumbnail, episode, released, upcoming, watched, progress, scheduled, children, ref, ...props }) {
|
||||
const blurThumbnail = profile.settings.hideSpoilers && season && episode && !watched;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selected && ref.current) {
|
||||
if ((progress && watched) || !watched) {
|
||||
ref.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'start'
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
return (
|
||||
<Button {...props} className={classnames(className, styles['video-container'])} title={title}>
|
||||
<Button {...props} ref={ref} className={classnames(className, styles['video-container'], { [styles['selected']]: selected })} title={title}>
|
||||
{
|
||||
typeof thumbnail === 'string' && thumbnail.length > 0 ?
|
||||
<div className={styles['thumbnail-container']}>
|
||||
|
|
@ -107,12 +122,12 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc
|
|||
{
|
||||
released instanceof Date && !isNaN(released.getTime()) ?
|
||||
<div className={styles['released-container']}>
|
||||
{released.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
{released.toLocaleString(profile.settings.interfaceLanguage, { year: 'numeric', month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
:
|
||||
scheduled ?
|
||||
<div className={styles['released-container']} title={'To be announced'}>
|
||||
TBA
|
||||
<div className={styles['released-container']} title={t('TBA')}>
|
||||
{t('TBA')}
|
||||
</div>
|
||||
:
|
||||
null
|
||||
|
|
@ -121,7 +136,7 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc
|
|||
{
|
||||
upcoming && !watched ?
|
||||
<div className={styles['upcoming-container']}>
|
||||
<div className={styles['flag-label']}>Upcoming</div>
|
||||
<div className={styles['flag-label']}>{t('UPCOMING')}</div>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
|
|
@ -130,7 +145,7 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc
|
|||
watched ?
|
||||
<div className={styles['watched-container']}>
|
||||
<Icon className={styles['flag-icon']} name={'eye'} />
|
||||
<div className={styles['flag-label']}>Watched</div>
|
||||
<div className={styles['flag-label']}>{t('CTX_WATCHED')}</div>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
|
|
@ -141,14 +156,14 @@ const Video = ({ className, id, title, thumbnail, season, episode, released, upc
|
|||
{children}
|
||||
</Button>
|
||||
);
|
||||
}, []);
|
||||
}, [selected]);
|
||||
const renderMenu = React.useMemo(() => function renderMenu() {
|
||||
return (
|
||||
<div className={styles['context-menu-content']} onPointerDown={popupMenuOnPointerDown} onContextMenu={popupMenuOnContextMenu} onClick={popupMenuOnClick} onKeyDown={popupMenuOnKeyDown}>
|
||||
<Button className={styles['context-menu-option-container']} title={'Watch'}>
|
||||
<Button className={styles['context-menu-option-container']} title={t('CTX_WATCH')}>
|
||||
<div className={styles['context-menu-option-label']}>{t('CTX_WATCH')}</div>
|
||||
</Button>
|
||||
<Button className={styles['context-menu-option-container']} title={watched ? 'Mark as non-watched' : 'Mark as watched'} onClick={toggleWatchedOnClick}>
|
||||
<Button className={styles['context-menu-option-container']} title={watched ? t('CTX_MARK_NON_WATCHED') : t('CTX_MARK_WATCHED')} onClick={toggleWatchedOnClick}>
|
||||
<div className={styles['context-menu-option-label']}>{watched ? t('CTX_MARK_NON_WATCHED') : t('CTX_MARK_WATCHED')}</div>
|
||||
</Button>
|
||||
<Button className={styles['context-menu-option-container']} title={seasonWatched ? t('CTX_UNMARK_REST') : t('CTX_MARK_REST')} onClick={toggleWatchedSeasonOnClick}>
|
||||
|
|
@ -202,6 +217,7 @@ Video.propTypes = {
|
|||
progress: PropTypes.number,
|
||||
scheduled: PropTypes.bool,
|
||||
seasonWatched: PropTypes.bool,
|
||||
selected: PropTypes.bool,
|
||||
deepLinks: PropTypes.shape({
|
||||
metaDetailsStreams: PropTypes.string,
|
||||
player: PropTypes.string
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@
|
|||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
border: 0.15rem solid transparent;
|
||||
|
||||
@supports (scroll-margin: 1.25rem) {
|
||||
scroll-margin: 1.25rem;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
|
|
@ -172,6 +177,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
animation: border 3s ease-in-out forwards;
|
||||
}
|
||||
|
||||
@keyframes border {
|
||||
0% {
|
||||
border: 0.15rem solid var(--primary-accent-color);
|
||||
}
|
||||
100% {
|
||||
border: 0.15rem solid transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.context-menu-container {
|
||||
max-width: calc(90% - 1.5rem);
|
||||
z-index: 2;
|
||||
|
|
|
|||
|
|
@ -19,11 +19,13 @@ import ModalDialog from './ModalDialog';
|
|||
import Multiselect from './Multiselect';
|
||||
import MultiselectMenu from './MultiselectMenu';
|
||||
import { HorizontalNavBar, VerticalNavBar } from './NavBar';
|
||||
import NumberInput from './NumberInput';
|
||||
import Popup from './Popup';
|
||||
import RadioButton from './RadioButton';
|
||||
import SearchBar from './SearchBar';
|
||||
import SharePrompt from './SharePrompt';
|
||||
import Slider from './Slider';
|
||||
import ShortcutsGroup from './ShortcutsGroup';
|
||||
import TextInput from './TextInput';
|
||||
import Toggle from './Toggle';
|
||||
import Transition from './Transition';
|
||||
|
|
@ -52,11 +54,13 @@ export {
|
|||
MultiselectMenu,
|
||||
HorizontalNavBar,
|
||||
VerticalNavBar,
|
||||
NumberInput,
|
||||
Popup,
|
||||
RadioButton,
|
||||
SearchBar,
|
||||
SharePrompt,
|
||||
Slider,
|
||||
ShortcutsGroup,
|
||||
TextInput,
|
||||
Toggle,
|
||||
Transition,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
<div id="app"></div>
|
||||
<%= htmlWebpackPlugin.tags.bodyTags %>
|
||||
<script src="//www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
|
||||
<script async src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -36,7 +36,7 @@ i18n
|
|||
const root = ReactDOM.createRoot(document.getElementById('app'));
|
||||
root.render(<App />);
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||
if (process.env.NODE_ENV === 'production' && process.env.SERVICE_WORKER_DISABLED !== 'true' && process.env.SERVICE_WORKER_DISABLED !== true && 'serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('service-worker.js')
|
||||
.catch((registrationError) => {
|
||||
|
|
|
|||
2
src/modules.d.ts
vendored
2
src/modules.d.ts
vendored
|
|
@ -3,4 +3,6 @@ declare module '*.less' {
|
|||
export = resource;
|
||||
}
|
||||
|
||||
declare module 'stremio-router';
|
||||
declare module 'stremio/components/NavBar';
|
||||
declare module 'stremio/components/ModalDialog';
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ const Addon = ({ className, id, name, version, logo, description, types, behavio
|
|||
</div>
|
||||
{
|
||||
typeof version === 'string' && version.length > 0 ?
|
||||
<div className={styles['version-container']} title={`v.${version}`}>v.{version}</div>
|
||||
<div className={styles['version-container']} title={t('ADDON_VERSION_SHORT', {version})}>{t('ADDON_VERSION_SHORT', {version})}</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const classnames = require('classnames');
|
|||
const { useTranslation } = require('react-i18next');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { usePlatform, useBinaryState, withCoreSuspender } = require('stremio/common');
|
||||
const { AddonDetailsModal, Button, Image, MainNavBars, Multiselect, ModalDialog, SearchBar, SharePrompt, TextInput } = require('stremio/components');
|
||||
const { AddonDetailsModal, Button, Image, MainNavBars, ModalDialog, SearchBar, SharePrompt, TextInput, MultiselectMenu } = require('stremio/components');
|
||||
const { useServices } = require('stremio/services');
|
||||
const Addon = require('./Addon');
|
||||
const useInstalledAddons = require('./useInstalledAddons');
|
||||
|
|
@ -107,7 +107,7 @@ const Addons = ({ urlParams, queryParams }) => {
|
|||
<div className={styles['addons-content']}>
|
||||
<div className={styles['selectable-inputs-container']}>
|
||||
{selectInputs.map((selectInput, index) => (
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
{...selectInput}
|
||||
key={index}
|
||||
className={styles['select-input-container']}
|
||||
|
|
@ -124,7 +124,7 @@ const Addons = ({ urlParams, queryParams }) => {
|
|||
value={search}
|
||||
onChange={searchInputOnChange}
|
||||
/>
|
||||
<Button className={styles['filter-button']} title={'All filters'} onClick={openFiltersModal}>
|
||||
<Button className={styles['filter-button']} title={t('ALL_FILTERS')} onClick={openFiltersModal}>
|
||||
<Icon className={styles['filter-icon']} name={'filters'} />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -132,12 +132,12 @@ const Addons = ({ urlParams, queryParams }) => {
|
|||
installedAddons.selected !== null ?
|
||||
installedAddons.selectable.types.length === 0 ?
|
||||
<div className={styles['message-container']}>
|
||||
No addons ware installed!
|
||||
{t('NO_ADDONS')}
|
||||
</div>
|
||||
:
|
||||
installedAddons.catalog.length === 0 ?
|
||||
<div className={styles['message-container']}>
|
||||
No addons ware installed for that type!
|
||||
{t('NO_ADDONS_FOR_TYPE')}
|
||||
</div>
|
||||
:
|
||||
<div className={styles['addons-list-container']}>
|
||||
|
|
@ -216,9 +216,9 @@ const Addons = ({ urlParams, queryParams }) => {
|
|||
</div>
|
||||
{
|
||||
filtersModalOpen ?
|
||||
<ModalDialog title={'Addons filters'} className={styles['filters-modal']} onCloseRequest={closeFiltersModal}>
|
||||
<ModalDialog title={t('ADDONS_FILTERS')} className={styles['filters-modal']} onCloseRequest={closeFiltersModal}>
|
||||
{selectInputs.map((selectInput, index) => (
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
{...selectInput}
|
||||
key={index}
|
||||
className={styles['select-input-container']}
|
||||
|
|
@ -265,7 +265,7 @@ const Addons = ({ urlParams, queryParams }) => {
|
|||
<span className={styles['name']}>{typeof sharedAddon.manifest.name === 'string' && sharedAddon.manifest.name.length > 0 ? sharedAddon.manifest.name : sharedAddon.manifest.id}</span>
|
||||
{
|
||||
typeof sharedAddon.manifest.version === 'string' && sharedAddon.manifest.version.length > 0 ?
|
||||
<span className={styles['version']}>v. {sharedAddon.manifest.version}</span>
|
||||
<span className={styles['version']}>{t('ADDON_VERSION_SHORT', { version: sharedAddon.manifest.version })}</span>
|
||||
:
|
||||
null
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue