Compare commits
405 commits
v5.0.0-bet
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5db6df4fc2 | ||
|
|
5580da8ecf | ||
|
|
38a811ce75 | ||
|
|
de78766151 | ||
|
|
5bc74a9da4 | ||
|
|
fe651a2cca | ||
|
|
377d57cf16 | ||
|
|
694fb833aa | ||
|
|
f74f9aafa0 | ||
|
|
bbfe25177b | ||
|
|
5560813b37 | ||
|
|
8fcffa2c3e | ||
|
|
3e93afbe21 | ||
|
|
81fdb18536 | ||
|
|
5e8db65551 | ||
|
|
a322de9093 | ||
|
|
35970825fa | ||
|
|
7b30c4e805 | ||
|
|
b48740f5c2 | ||
|
|
79a771cbc3 | ||
|
|
a1e19f0ea5 | ||
|
|
73917c14a4 | ||
|
|
bf65b6001e | ||
|
|
35b7b7082c | ||
|
|
33d09916cb | ||
|
|
29d491d00b | ||
|
|
3ce58df582 | ||
|
|
7cd439a656 | ||
|
|
9e0dd0e942 | ||
|
|
b003f64f4f | ||
|
|
b058eb536e | ||
|
|
0502c9e47a | ||
|
|
1f293b7cbc | ||
|
|
b69165e868 | ||
|
|
234d83ad34 | ||
|
|
3052a724f4 | ||
|
|
880304f46f | ||
|
|
e860752f41 | ||
|
|
b2d81c09ee | ||
|
|
49240a4e0f | ||
|
|
129f510047 | ||
|
|
33c3f6d9f0 | ||
|
|
117f932596 | ||
|
|
de5d43225b | ||
|
|
aa76396a84 | ||
|
|
c99d7c5d82 | ||
|
|
4e735afacc | ||
|
|
6dfd0fcbde | ||
|
|
1b9e2a194f | ||
|
|
3f5dedd072 | ||
|
|
0e1d22d279 | ||
|
|
eb23d3e4db | ||
|
|
73409fff8c | ||
|
|
70bcf339be | ||
|
|
50cd992f1c | ||
|
|
c131d7f75e | ||
|
|
e260e52322 | ||
|
|
de32e3a765 | ||
|
|
6cb0c75555 | ||
|
|
0a88fe5d01 | ||
|
|
ccc5832611 | ||
|
|
c2c18bc4f6 | ||
|
|
e0961ec686 | ||
|
|
fcd85bdcf4 | ||
|
|
9fe7430bc7 | ||
|
|
9dd44a7ef1 | ||
|
|
f2023c5a5a | ||
|
|
ebc7c5bdde | ||
|
|
070807f148 | ||
|
|
d2199e49f0 | ||
|
|
2cc122ea1c | ||
|
|
079d37cd77 | ||
|
|
003ea9f2dc | ||
|
|
7ab75cfc88 | ||
|
|
504a3bb0be | ||
|
|
1f56ddf8e3 | ||
|
|
80e065778f | ||
|
|
0286994773 | ||
|
|
3fdda2cdad | ||
|
|
866db891ef | ||
|
|
48ba0b8fba | ||
|
|
0c78e39a02 | ||
|
|
89233243f2 | ||
|
|
6a981f275b | ||
|
|
74f1d660be | ||
|
|
562854d902 | ||
|
|
effa6e430e | ||
|
|
3c2e144f1d | ||
|
|
70ea2a5772 | ||
|
|
ca46494480 | ||
|
|
b1a2d0354b | ||
|
|
50ab7d9f23 | ||
|
|
b32cf42b96 | ||
|
|
2f4dff7dd8 | ||
|
|
1565b85ea3 | ||
|
|
7823a783ea | ||
|
|
6e53d5131e | ||
|
|
eb4f4b9cce | ||
|
|
041748ef71 | ||
|
|
c3f67454ff | ||
|
|
89515a2a75 | ||
|
|
5b83fa00b5 | ||
|
|
182782a60f | ||
|
|
b24426250b | ||
|
|
cdb65a7973 | ||
|
|
097857d27c | ||
|
|
31a5cc6f1a | ||
|
|
eef3cef1d4 | ||
|
|
3d119db049 | ||
|
|
df69e6eb18 | ||
|
|
6f0e7aa290 | ||
|
|
389a91aeca | ||
|
|
d8278e99da | ||
|
|
0567bbd8ac | ||
|
|
c8d107d036 | ||
|
|
c8f3a70f41 | ||
|
|
8e87d2515b | ||
|
|
cc799b610f | ||
|
|
83ff6d54a4 | ||
|
|
2f0005080b | ||
|
|
14765d8c22 | ||
|
|
8367dce25d | ||
|
|
68c0a4fb13 | ||
|
|
e26c33fe33 | ||
|
|
091f94e8fd | ||
|
|
3f5097f0a0 | ||
|
|
ac9ca71b53 | ||
|
|
fab318e647 | ||
|
|
c95f314a50 | ||
|
|
8623627f4d | ||
|
|
b49eb516fd | ||
|
|
df0d24a7d9 | ||
|
|
3098f6417f | ||
|
|
ec6db02829 | ||
|
|
1e0963e8af | ||
|
|
ce2c021e5f | ||
|
|
6bd28847f2 | ||
|
|
a77faea0b9 | ||
|
|
93ed428e8b | ||
|
|
fa13597748 | ||
|
|
c368951952 | ||
|
|
0a1746dfe2 | ||
|
|
e29adde4bd | ||
|
|
c28a52a73c | ||
|
|
0df0cdb44b | ||
|
|
1c9813ebc9 | ||
|
|
dbed391a86 | ||
|
|
9503e90e54 | ||
|
|
3cad491040 | ||
|
|
d456adff0e | ||
|
|
1c441b9bc0 | ||
|
|
8911473210 | ||
|
|
54b017c39f | ||
|
|
ea5e302af7 | ||
|
|
67358359bf | ||
|
|
92d0644c9f | ||
|
|
9c478148bc | ||
|
|
58e38a6077 | ||
|
|
f2c9bb6d88 | ||
|
|
86db4c47e4 | ||
|
|
3b7944a6f7 | ||
|
|
4ce5ef3744 | ||
|
|
370443609b | ||
|
|
cf0ff1f4ca | ||
|
|
17ee0e95e4 | ||
|
|
a5265bacf9 | ||
|
|
e6e05573cb | ||
|
|
cce556e639 | ||
|
|
15575ee699 | ||
|
|
e85b67268d | ||
|
|
89fbbb3451 | ||
|
|
a30307789c | ||
|
|
11f51dd86c | ||
|
|
9853c28683 | ||
|
|
0dcc07c469 | ||
|
|
cd111942a5 | ||
|
|
8339dc8a00 | ||
|
|
39741d1372 | ||
|
|
7cd49b516f | ||
|
|
5aaee64549 | ||
|
|
f046e65e73 | ||
|
|
487fde70e0 | ||
|
|
e11366b374 | ||
|
|
9966ae43d5 | ||
|
|
889dface67 | ||
|
|
bb0aecc194 | ||
|
|
64b13d6092 | ||
|
|
a11df877a6 | ||
|
|
f37e119644 | ||
|
|
9dbf950a40 | ||
|
|
55b86179ca | ||
|
|
3e8c9999fe | ||
|
|
0b179b88e8 | ||
|
|
da675cd56c | ||
|
|
9b3b0d67ba | ||
|
|
fc2d906a42 | ||
|
|
c15ca17d2d | ||
|
|
b9540af66f | ||
|
|
55963fd23e | ||
|
|
80066b2f3f | ||
|
|
c8dfc31e6b | ||
|
|
84a172d1bf | ||
|
|
6fbc08a720 | ||
|
|
2bc0f3468c | ||
|
|
c9a40aabd7 | ||
|
|
7046622fb6 | ||
|
|
5dc088b798 | ||
|
|
b5bd75fd94 | ||
|
|
16b2eb8d17 | ||
|
|
c4ab2dc546 | ||
|
|
227f21c10f | ||
|
|
d21be690de | ||
|
|
6c7a2755fb | ||
|
|
673c22a014 | ||
|
|
07d2744f66 | ||
|
|
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 | ||
|
|
e8bee4997a | ||
|
|
71e0bb4481 | ||
|
|
6bf3b8147d | ||
|
|
f73fa5931e | ||
|
|
a9d9c8d808 | ||
|
|
c70211153e | ||
|
|
5eb55d3aaf | ||
|
|
e1e6fe075b | ||
|
|
9ccc6b8271 | ||
|
|
67f4f349bb | ||
|
|
97c3b7d004 | ||
|
|
373ccf351a | ||
|
|
987201edd3 | ||
|
|
ff08e377fc | ||
|
|
c3f1f6c911 | ||
|
|
3b2d1f365c | ||
|
|
852f478f1e | ||
|
|
6833bb719d | ||
|
|
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 | ||
|
|
2de2e89446 | ||
|
|
ea69521912 | ||
|
|
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 | ||
|
|
185740c834 | ||
|
|
b9b79a833d | ||
|
|
ed0ca136d1 | ||
|
|
4001ea3acc | ||
|
|
88ed546414 | ||
|
|
f271c97502 | ||
|
|
672dbdeb28 | ||
|
|
d177f86018 | ||
|
|
80f25b8d45 | ||
|
|
53dfddec74 | ||
|
|
3c2f8cb89b | ||
|
|
0bec58b158 | ||
|
|
56989781c8 | ||
|
|
746e5ba0d8 | ||
|
|
0e3aa9c2c8 | ||
|
|
5ab324f125 | ||
|
|
ea5d05c31d | ||
|
|
881c808003 | ||
|
|
d2db62f33a | ||
|
|
3b730a2bd8 |
8
.github/workflows/auto_assign.yml
vendored
|
|
@ -13,8 +13,8 @@ jobs:
|
|||
steps:
|
||||
# Auto assign PR to author
|
||||
- name: Auto Assign PR to Author
|
||||
if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@v8
|
||||
if: github.event.pull_request.head.repo.fork == false && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
|
@ -31,10 +31,10 @@ jobs:
|
|||
|
||||
# Dynamic labeling based on PR/Issue title
|
||||
- name: Label PRs and Issues
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
if: github.event.pull_request.head.repo.fork == false && github.actor != 'dependabot[bot]'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
|
|
|||
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@v5
|
||||
uses: actions/checkout@v6
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v5
|
||||
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
|
||||
|
|
|
|||
33
.github/workflows/pages_cleanup.yml
vendored
|
|
@ -9,22 +9,39 @@ permissions:
|
|||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: gh-pages
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Delete directories older than 1 year
|
||||
- name: Delete directories that don't have existing branch
|
||||
run: |
|
||||
for dir in $(find . -mindepth 1 -maxdepth 2 -type d -not -path '*/\.*'); do
|
||||
if ! git log -1 --since="1 year ago" -- "$dir" | grep -q .; then
|
||||
echo "Deleting $dir"
|
||||
rm -rf "$dir"
|
||||
fi
|
||||
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
|
||||
|
|
|
|||
17
.github/workflows/release.yml
vendored
|
|
@ -9,20 +9,25 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Install NPM dependencies
|
||||
run: npm install
|
||||
uses: actions/checkout@v6
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
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.11.2
|
||||
uses: svenstaro/upload-release-action@2.11.5
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: stremio-web.zip
|
||||
asset_name: stremio-web.zip
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
overwrite: true
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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"]
|
||||
|
|
|
|||
23
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,39 +10,46 @@ 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
|
||||
|
||||
### Board
|
||||
|
||||

|
||||

|
||||
|
||||
### Discover
|
||||
|
||||

|
||||

|
||||
|
||||
### Meta Details
|
||||
|
||||

|
||||

|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
BIN
assets/fonts/TwemojiFlags.woff2
Normal file
|
Before Width: | Height: | Size: 652 KiB After Width: | Height: | Size: 652 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
BIN
assets/images/icon_196x196.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
assets/images/icon_512x512.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
BIN
assets/images/maskable_icon_196x196.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
assets/images/maskable_icon_512x512.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
BIN
assets/screenshots/board.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 159 KiB |
BIN
assets/screenshots/discover.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
assets/screenshots/metadetails.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
|
|
@ -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',
|
||||
|
|
|
|||
59
manifest.json
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"name": "Stremio Web",
|
||||
"short_name": "Stremio",
|
||||
"description": "Freedom To Stream",
|
||||
"background_color": "#161523",
|
||||
"theme_color": "#2a2843",
|
||||
"orientation": "any",
|
||||
"display": "standalone",
|
||||
"display_override": ["standalone"],
|
||||
"scope": "./",
|
||||
"start_url": "./",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicons/icon_256x256.ico",
|
||||
"sizes": "256x256",
|
||||
"type": "image/vnd.microsoft.icon"
|
||||
},
|
||||
{
|
||||
"src": "images/maskable_icon_512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "images/maskable_icon_196x196.png",
|
||||
"sizes": "196x196",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "images/icon_512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "images/icon_196x196.png",
|
||||
"sizes": "196x196",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "screenshots/board_wide.webp",
|
||||
"sizes": "1440x900",
|
||||
"type": "image/webp",
|
||||
"form_factor": "wide",
|
||||
"label": "Homescreen of Stremio"
|
||||
},
|
||||
{
|
||||
"src": "screenshots/board_narrow.webp",
|
||||
"sizes": "414x896",
|
||||
"type": "image/webp",
|
||||
"form_factor": "narrow",
|
||||
"label": "Homescreen of Stremio"
|
||||
}
|
||||
]
|
||||
}
|
||||
15562
package-lock.json
generated
21
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "stremio",
|
||||
"displayName": "Stremio",
|
||||
"version": "5.0.0-beta.27",
|
||||
"version": "5.0.0-beta.34",
|
||||
"author": "Smart Code OOD",
|
||||
"private": true,
|
||||
"license": "gpl-2.0",
|
||||
|
|
@ -11,27 +11,27 @@
|
|||
"build": "webpack --mode production",
|
||||
"test": "jest",
|
||||
"lint": "eslint src",
|
||||
"scan-translations": "npx jest ./tests/i18nScan.test.js"
|
||||
"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.4",
|
||||
"@stremio/stremio-icons": "5.7.1",
|
||||
"@stremio/stremio-video": "0.0.62",
|
||||
"@stremio/stremio-core-web": "0.56.4",
|
||||
"@stremio/stremio-icons": "5.8.0",
|
||||
"@stremio/stremio-video": "0.0.75",
|
||||
"a-color-picker": "1.2.1",
|
||||
"bowser": "2.11.0",
|
||||
"buffer": "6.0.3",
|
||||
"classnames": "2.5.1",
|
||||
"eventemitter3": "5.0.1",
|
||||
"fast-equals": "^6.0.0",
|
||||
"filter-invalid-dom-props": "3.0.1",
|
||||
"hat": "^0.0.3",
|
||||
"i18next": "^24.0.5",
|
||||
"langs": "github:Stremio/nodejs-langs",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"lodash.intersection": "4.4.0",
|
||||
"lodash.isequal": "4.5.0",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"magnet-uri": "6.2.0",
|
||||
"prop-types": "15.8.1",
|
||||
|
|
@ -41,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#abe7684165a031755e9aee39da26daa806ba7824",
|
||||
"stremio-translations": "github:Stremio/stremio-translations#90ea718c18750a0e9cd6824b0ef7c512a41cb90b",
|
||||
"url": "0.11.4",
|
||||
"use-long-press": "^3.2.0"
|
||||
},
|
||||
|
|
@ -50,15 +50,13 @@
|
|||
"@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",
|
||||
"clean-webpack-plugin": "4.0.0",
|
||||
"copy-webpack-plugin": "12.0.2",
|
||||
"css-loader": "6.11.0",
|
||||
"cssnano": "7.0.6",
|
||||
|
|
@ -82,7 +80,6 @@
|
|||
"webpack": "5.97.0",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-dev-server": "^5.1.0",
|
||||
"webpack-pwa-manifest": "^4.3.0",
|
||||
"workbox-webpack-plugin": "^7.3.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10076
pnpm-lock.yaml
Normal file
|
Before Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 947 KiB |
|
Before Width: | Height: | Size: 1.2 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 = () => {
|
||||
|
|
@ -159,7 +168,8 @@ const App = () => {
|
|||
services.core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'PullUserFromAPI'
|
||||
action: 'PullUserFromAPI',
|
||||
args: {}
|
||||
}
|
||||
});
|
||||
services.core.transport.dispatch({
|
||||
|
|
@ -203,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>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const ErrorDialog = ({ className }) => {
|
|||
<div className={classnames(className, styles['error-container'])}>
|
||||
<Image
|
||||
className={styles['error-image']}
|
||||
src={require('/images/empty.png')}
|
||||
src={require('/assets/images/empty.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
<div className={styles['error-message']}>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const isEqual = require('lodash.isequal');
|
||||
const { deepEqual } = require('fast-equals');
|
||||
const { withCoreSuspender, useProfile, useToast } = require('stremio/common');
|
||||
const { useServices } = require('stremio/services');
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ const SearchParamsHandler = () => {
|
|||
|
||||
setSearchParams((previousSearchParams) => {
|
||||
const currentSearchParams = Object.fromEntries(searchParams.entries());
|
||||
return isEqual(previousSearchParams, currentSearchParams) ? previousSearchParams : currentSearchParams;
|
||||
return deepEqual(previousSearchParams, currentSearchParams) ? previousSearchParams : currentSearchParams;
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const ServicesToaster = () => {
|
|||
}
|
||||
case 'MagnetParsed': {
|
||||
toast.show({
|
||||
type: 'success',
|
||||
type: 'info',
|
||||
title: 'Magnet link parsed',
|
||||
timeout: 4000
|
||||
});
|
||||
|
|
|
|||
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
|
|
@ -0,0 +1,2 @@
|
|||
import ShortcutsModal from './ShortcutsModal';
|
||||
export default ShortcutsModal;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,13 @@
|
|||
|
||||
@font-face {
|
||||
font-family: 'PlusJakartaSans';
|
||||
src: url('/fonts/PlusJakartaSans.ttf') format('truetype');
|
||||
src: url('/assets/fonts/PlusJakartaSans.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'TwemojiFlags';
|
||||
src: url('/assets/fonts/TwemojiFlags.woff2') format('woff2');
|
||||
unicode-range: U+1F1E6-1F1FF;
|
||||
}
|
||||
|
||||
:global {
|
||||
|
|
@ -23,8 +29,8 @@
|
|||
// HTML sizes
|
||||
@html-width: ~"calc(max(var(--small-viewport-width), var(--dynamic-viewport-width)))";
|
||||
@html-height: ~"calc(max(var(--small-viewport-height), var(--dynamic-viewport-height)))";
|
||||
@html-standalone-width: ~"calc(max(100%, var(--small-viewport-width)))";
|
||||
@html-standalone-height: ~"calc(max(100%, var(--small-viewport-height)))";
|
||||
@html-standalone-width: ~"calc(max(100%, var(--large-viewport-width)))";
|
||||
@html-standalone-height: ~"calc(max(100%, var(--large-viewport-height)))";
|
||||
|
||||
// Safe area insets
|
||||
@safe-area-inset-top: env(safe-area-inset-top, 0rem);
|
||||
|
|
@ -35,7 +41,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 +54,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 +75,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,11 +157,12 @@ svg {
|
|||
html {
|
||||
width: @html-width;
|
||||
height: @html-height;
|
||||
font-family: 'PlusJakartaSans', 'Arial', 'Helvetica', 'sans-serif';
|
||||
font-family: 'PlusJakartaSans', 'Arial', 'Helvetica', 'TwemojiFlags', 'sans-serif';
|
||||
overflow: auto;
|
||||
overscroll-behavior: none;
|
||||
user-select: none;
|
||||
touch-action: manipulation;
|
||||
background-color: var(--primary-background-color);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
@media (display-mode: standalone) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
const CHROMECAST_RECEIVER_APP_ID = '1634F54B';
|
||||
const DEFAULT_STREAMING_SERVER_URL = 'http://127.0.0.1:11470/';
|
||||
const DEFAULT_SUBTITLES_LANGUAGE = 'eng';
|
||||
const LOCAL_SUBTITLES_LANGUAGE = 'local';
|
||||
const SUBTITLES_SIZES = [75, 100, 125, 150, 175, 200, 250];
|
||||
const SUBTITLES_FONTS = ['PlusJakartaSans', 'Arial', 'Halvetica', 'Times New Roman', 'Verdana', 'Courier', 'Lucida Console', 'sans-serif', 'serif', 'monospace'];
|
||||
const SEEK_TIME_DURATIONS = [3000, 5000, 10000, 15000, 20000, 30000];
|
||||
|
|
@ -97,6 +99,16 @@ const EXTERNAL_PLAYERS = [
|
|||
value: 'moonplayer',
|
||||
platforms: ['visionos'],
|
||||
},
|
||||
{
|
||||
label: 'Infuse',
|
||||
value: 'infuse',
|
||||
platforms: ['ios', 'visionos', 'macos'],
|
||||
},
|
||||
{
|
||||
label: 'Vidhub',
|
||||
value: 'vidhub',
|
||||
platforms: ['ios'],
|
||||
},
|
||||
{
|
||||
label: 'M3U Playlist',
|
||||
value: 'm3u',
|
||||
|
|
@ -111,6 +123,8 @@ const PROTOCOL = 'stremio:';
|
|||
module.exports = {
|
||||
CHROMECAST_RECEIVER_APP_ID,
|
||||
DEFAULT_STREAMING_SERVER_URL,
|
||||
DEFAULT_SUBTITLES_LANGUAGE,
|
||||
LOCAL_SUBTITLES_LANGUAGE,
|
||||
SUBTITLES_SIZES,
|
||||
SUBTITLES_FONTS,
|
||||
SEEK_TIME_DURATIONS,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ const PlatformProvider = ({ children }: Props) => {
|
|||
const openExternal = (url: string) => {
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
const isWhitelisted = WHITELISTED_HOSTS.some((host: string) => hostname.endsWith(host));
|
||||
const isWhitelisted = WHITELISTED_HOSTS.some((host: string) =>
|
||||
hostname === host || hostname.endsWith('.' + host)
|
||||
);
|
||||
const finalUrl = !isWhitelisted ? `https://www.stremio.com/warning#${encodeURIComponent(url)}` : url;
|
||||
|
||||
window.open(finalUrl, '_blank');
|
||||
|
|
|
|||
|
|
@ -11,13 +11,21 @@ const APPLE_MOBILE_DEVICES = [
|
|||
|
||||
const { userAgent, platform, maxTouchPoints } = globalThis.navigator;
|
||||
|
||||
// this detects ipad properly in safari
|
||||
// while bowser does not
|
||||
const isIOS = APPLE_MOBILE_DEVICES.includes(platform) || (userAgent.includes('Mac') && 'ontouchend' in document);
|
||||
// Vision Pro uniquely supports the WebXR Device API (navigator.xr),
|
||||
// while iPads and iPhones do not — this is the most reliable discriminator.
|
||||
// Both Vision Pro and iPads (iPadOS 13+) report 'Macintosh' in the UA
|
||||
// and have maxTouchPoints > 1, so we cannot rely on those alone.
|
||||
const isMacLikeWithTouch = userAgent.includes('Macintosh') && maxTouchPoints > 1;
|
||||
const isVisionOS = isMacLikeWithTouch && 'xr' in globalThis.navigator;
|
||||
|
||||
// Edge case: iPad is included in this function
|
||||
// Keep in mind maxTouchPoints for Vision Pro might change in the future
|
||||
const isVisionOS = userAgent.includes('Macintosh') && maxTouchPoints === 5;
|
||||
// Detect iOS/iPadOS devices:
|
||||
// - Older iPads expose 'iPad' in navigator.platform
|
||||
// - iPadOS 13+ exposes 'MacIntel' but has touch support ('ontouchend' in document)
|
||||
// - Exclude Vision OS devices which also pass the touch check
|
||||
const isIOS = !isVisionOS && (
|
||||
APPLE_MOBILE_DEVICES.includes(platform) ||
|
||||
(userAgent.includes('Mac') && 'ontouchend' in document)
|
||||
);
|
||||
|
||||
const bowser = Bowser.getParser(userAgent);
|
||||
const os = bowser.getOSName().toLowerCase();
|
||||
|
|
|
|||
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
|
|
@ -0,0 +1,8 @@
|
|||
import { ShortcutsProvider, useShortcuts } from './Shortcuts';
|
||||
import onShortcut from './onShortcut';
|
||||
|
||||
export {
|
||||
ShortcutsProvider,
|
||||
useShortcuts,
|
||||
onShortcut,
|
||||
};
|
||||
16
src/common/Shortcuts/onShortcut.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { DependencyList, useCallback, useEffect } from 'react';
|
||||
import { ShortcutListener, ShortcutName, useShortcuts } from './Shortcuts';
|
||||
|
||||
const onShortcut = (name: ShortcutName, listener: ShortcutListener, deps: DependencyList, enabled = true) => {
|
||||
const shortcuts = useShortcuts();
|
||||
|
||||
const listenerCallback = useCallback(listener, deps);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
shortcuts.on(name, listenerCallback);
|
||||
return () => shortcuts.off(name, listenerCallback);
|
||||
}, [listenerCallback, enabled]);
|
||||
};
|
||||
|
||||
export default onShortcut;
|
||||
124
src/common/Shortcuts/shortcuts.json
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
[
|
||||
{
|
||||
"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": "speedDown",
|
||||
"label": "SETTINGS_SHORTCUT_DECREASE_PLAYBACK_SPEED",
|
||||
"combos": [["["]]
|
||||
},
|
||||
{
|
||||
"name": "speedUp",
|
||||
"label": "SETTINGS_SHORTCUT_INCREASE_PLAYBACK_SPEED",
|
||||
"combos": [["]"]]
|
||||
},
|
||||
{
|
||||
"name": "toggleSubtitles",
|
||||
"label": "SETTINGS_SHORTCUT_TOGGLE_SUBTITLES",
|
||||
"combos": [["C"]]
|
||||
},
|
||||
{
|
||||
"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"]]
|
||||
},
|
||||
{
|
||||
"name": "playNext",
|
||||
"label": "SETTINGS_SHORTCUT_PLAY_NEXT",
|
||||
"combos": [["Shift", "N"]]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
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[],
|
||||
};
|
||||
|
|
@ -6,6 +6,7 @@ const React = require('react');
|
|||
|
||||
const ToastContext = React.createContext({
|
||||
show: () => { },
|
||||
remove: () => { },
|
||||
clear: () => { }
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
&.error {
|
||||
.icon-container {
|
||||
.icon {
|
||||
color: var(--color-trakt);
|
||||
color: var(--danger-accent-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const ToastProvider = ({ className, children }) => {
|
|||
},
|
||||
show: (item) => {
|
||||
if (filters.some((filter) => filter(item))) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeout = typeof item.timeout === 'number' && !isNaN(item.timeout) ?
|
||||
|
|
@ -64,6 +64,11 @@ const ToastProvider = ({ className, children }) => {
|
|||
onClose: itemOnClose
|
||||
}
|
||||
});
|
||||
return id;
|
||||
},
|
||||
remove: (id) => {
|
||||
clearTimeout(id);
|
||||
dispatch({ type: 'remove', id });
|
||||
},
|
||||
clear: () => {
|
||||
dispatch({ type: 'clear' });
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@
|
|||
|
||||
.fade-active {
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s cubic-bezier(0.32, 0, 0.67, 0);
|
||||
transition: opacity 0.1s cubic-bezier(0.32, 0, 0.67, 0);
|
||||
}
|
||||
|
||||
.fade-exit {
|
||||
|
|
|
|||
|
|
@ -1,25 +0,0 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const comparatorWithPriorities = (priorities) => {
|
||||
return (a, b) => {
|
||||
if (isNaN(priorities[a]) && isNaN(priorities[b])) {
|
||||
return a.localeCompare(b);
|
||||
} else if (isNaN(priorities[a])) {
|
||||
if (priorities[b] === Number.NEGATIVE_INFINITY) {
|
||||
return -1;
|
||||
} else {
|
||||
return 1;
|
||||
}
|
||||
} else if (isNaN(priorities[b])) {
|
||||
if (priorities[a] === Number.NEGATIVE_INFINITY) {
|
||||
return 1;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
} else {
|
||||
return priorities[b] - priorities[a];
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = comparatorWithPriorities;
|
||||
|
|
@ -4,7 +4,7 @@ const { FileDropProvider, onFileDrop } = require('./FileDrop');
|
|||
const { PlatformProvider, usePlatform } = require('./Platform');
|
||||
const { ToastProvider, useToast } = require('./Toast');
|
||||
const { TooltipProvider, Tooltip } = require('./Tooltips');
|
||||
const comparatorWithPriorities = require('./comparatorWithPriorities');
|
||||
const { ShortcutsProvider, useShortcuts, onShortcut } = require('./Shortcuts');
|
||||
const CONSTANTS = require('./CONSTANTS');
|
||||
const { withCoreSuspender, useCoreSuspender } = require('./CoreSuspender');
|
||||
const getVisibleChildrenRange = require('./getVisibleChildrenRange');
|
||||
|
|
@ -25,6 +25,7 @@ const { default: useSettings } = require('./useSettings');
|
|||
const { default: useShell } = require('./useShell');
|
||||
const useStreamingServer = require('./useStreamingServer');
|
||||
const { default: useTimeout } = require('./useTimeout');
|
||||
const { default: usePlayUrl } = require('./usePlayUrl');
|
||||
const useTorrent = require('./useTorrent');
|
||||
const useTranslate = require('./useTranslate');
|
||||
const { default: useOrientation } = require('./useOrientation');
|
||||
|
|
@ -35,11 +36,13 @@ module.exports = {
|
|||
onFileDrop,
|
||||
PlatformProvider,
|
||||
usePlatform,
|
||||
ShortcutsProvider,
|
||||
useShortcuts,
|
||||
onShortcut,
|
||||
ToastProvider,
|
||||
useToast,
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
comparatorWithPriorities,
|
||||
CONSTANTS,
|
||||
withCoreSuspender,
|
||||
useCoreSuspender,
|
||||
|
|
@ -61,6 +64,7 @@ module.exports = {
|
|||
useShell,
|
||||
useStreamingServer,
|
||||
useTimeout,
|
||||
usePlayUrl,
|
||||
useTorrent,
|
||||
useTranslate,
|
||||
useOrientation,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
"name": "العربية",
|
||||
"codes": ["ar-AR", "ara"]
|
||||
},
|
||||
{
|
||||
"name": "Беларуская",
|
||||
"codes": ["be-BY", "bel"]
|
||||
},
|
||||
{
|
||||
"name": "български език",
|
||||
"codes": ["bg-BG", "bul"]
|
||||
|
|
@ -13,7 +17,7 @@
|
|||
},
|
||||
{
|
||||
"name": "català",
|
||||
"codes": ["ca-CA", "cat"]
|
||||
"codes": ["ca-ES", "cat"]
|
||||
},
|
||||
{
|
||||
"name": "čeština",
|
||||
|
|
@ -43,6 +47,10 @@
|
|||
"name": "español",
|
||||
"codes": ["es-ES", "spa"]
|
||||
},
|
||||
{
|
||||
"name": "Eesti",
|
||||
"codes": ["et-EE", "est"]
|
||||
},
|
||||
{
|
||||
"name": "euskara",
|
||||
"codes": ["eu-ES", "eus"]
|
||||
|
|
@ -51,6 +59,10 @@
|
|||
"name": "فارسی",
|
||||
"codes": ["fa-IR", "fas"]
|
||||
},
|
||||
{
|
||||
"name": "Suomi",
|
||||
"codes": ["fi-FI", "fin"]
|
||||
},
|
||||
{
|
||||
"name": "Français",
|
||||
"codes": ["fr-FR", "fre"]
|
||||
|
|
@ -87,6 +99,10 @@
|
|||
"name": "한국어",
|
||||
"codes": ["ko-KR", "kor"]
|
||||
},
|
||||
{
|
||||
"name": "Lietuvių",
|
||||
"codes": ["lt-LT", "ltu"]
|
||||
},
|
||||
{
|
||||
"name": "македонски јазик",
|
||||
"codes": ["mk-MK", "mkd"]
|
||||
|
|
@ -95,6 +111,10 @@
|
|||
"name": "ဗမာစာ",
|
||||
"codes": ["my-BM", "mya"]
|
||||
},
|
||||
{
|
||||
"name": "नेपाली",
|
||||
"codes": ["ne-NP", "nep"]
|
||||
},
|
||||
{
|
||||
"name": "Norsk bokmål",
|
||||
"codes": ["nb-NO", "nob"]
|
||||
|
|
@ -107,6 +127,10 @@
|
|||
"name": "Norsk nynorsk",
|
||||
"codes": ["nn-NO", "nno"]
|
||||
},
|
||||
{
|
||||
"name": "ਪੰਜਾਬੀ",
|
||||
"codes": ["pa-IN", "pan"]
|
||||
},
|
||||
{
|
||||
"name": "język polski",
|
||||
"codes": ["pl-PL", "pol"]
|
||||
|
|
@ -119,13 +143,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,10 +163,18 @@
|
|||
"name": "српски језик",
|
||||
"codes": ["sr-RS", "srp"]
|
||||
},
|
||||
{
|
||||
"name": "Svenska",
|
||||
"codes": ["sv-SE", "swe"]
|
||||
},
|
||||
{
|
||||
"name": "తెలుగు",
|
||||
"codes": ["te-IN", "tel"]
|
||||
},
|
||||
{
|
||||
"name": "தமிழ்",
|
||||
"codes": ["tl-TM", "tam"]
|
||||
},
|
||||
{
|
||||
"name": "Türkçe",
|
||||
"codes": ["tr-TR", "tur"]
|
||||
|
|
@ -147,6 +183,10 @@
|
|||
"name": "українська мова",
|
||||
"codes": ["uk-UA", "ukr"]
|
||||
},
|
||||
{
|
||||
"name": "اُرْدُو",
|
||||
"codes": ["ur-PK", "urd"]
|
||||
},
|
||||
{
|
||||
"name": "Tiếng Việt",
|
||||
"codes": ["vi-VN", "vie"]
|
||||
|
|
|
|||
|
|
@ -6,11 +6,16 @@ const all = langs.all().map((lang) => ({
|
|||
label: lang.local,
|
||||
alpha2: lang['1'],
|
||||
alpha3: [lang['2'], lang['2B'], lang['2T'], lang['3']],
|
||||
locale: lang['locale'],
|
||||
ietf: lang['ietf'],
|
||||
}));
|
||||
|
||||
const find = (code: string) => {
|
||||
return all.find(({ alpha2, alpha3, locale }) => [alpha2, ...alpha3, locale].includes(code));
|
||||
return all.find(({ alpha2, alpha3, ietf }) => [alpha2, ...alpha3, ietf].includes(code));
|
||||
};
|
||||
|
||||
const toCode = (code: string) => {
|
||||
const language = find(code);
|
||||
return language?.[2] ?? code;
|
||||
};
|
||||
|
||||
const label = (code: string) => {
|
||||
|
|
@ -21,5 +26,6 @@ const label = (code: string) => {
|
|||
export {
|
||||
all,
|
||||
find,
|
||||
toCode,
|
||||
label,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
const React = require('react');
|
||||
const throttle = require('lodash.throttle');
|
||||
const isEqual = require('lodash.isequal');
|
||||
const { deepEqual } = require('fast-equals');
|
||||
const intersection = require('lodash.intersection');
|
||||
const { useCoreSuspender } = require('stremio/common/CoreSuspender');
|
||||
const { useRouteFocused } = require('stremio-router');
|
||||
|
|
@ -19,7 +19,7 @@ const useModelState = ({ action, ...args }) => {
|
|||
const [state, setState] = React.useReducer(
|
||||
(prevState, nextState) => {
|
||||
return Object.keys(prevState).reduce((result, key) => {
|
||||
result[key] = isEqual(prevState[key], nextState[key]) ? prevState[key] : nextState[key];
|
||||
result[key] = deepEqual(prevState[key], nextState[key]) ? prevState[key] : nextState[key];
|
||||
return result;
|
||||
}, {});
|
||||
},
|
||||
|
|
|
|||
4
src/common/useNotifications.d.ts
vendored
|
|
@ -1,2 +1,2 @@
|
|||
declare const useNotifcations: () => Notifications;
|
||||
export = useNotifcations;
|
||||
declare const useNotifications: () => Notifications;
|
||||
export = useNotifications;
|
||||
|
|
|
|||
65
src/common/usePlayUrl.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { useCallback } from 'react';
|
||||
import magnet from 'magnet-uri';
|
||||
import { useServices } from 'stremio/services';
|
||||
import useToast from 'stremio/common/Toast/useToast';
|
||||
import useTorrent from 'stremio/common/useTorrent';
|
||||
import useStreamingServer from 'stremio/common/useStreamingServer';
|
||||
|
||||
const HTTP_REGEX = /^https?:\/\/.+/i;
|
||||
|
||||
const usePlayUrl = () => {
|
||||
const { core } = useServices();
|
||||
const toast = useToast();
|
||||
const { createTorrentFromMagnet } = useTorrent();
|
||||
const streamingServer = useStreamingServer();
|
||||
|
||||
const handlePlayUrl = useCallback(async (text: string): Promise<boolean> => {
|
||||
if (!text || !text.trim()) return false;
|
||||
const trimmed = text.trim();
|
||||
|
||||
if (HTTP_REGEX.test(trimmed)) {
|
||||
toast.show({
|
||||
type: 'success',
|
||||
title: 'Loading HTTP stream…',
|
||||
timeout: 3000
|
||||
});
|
||||
try {
|
||||
const encoded = await core.transport.encodeStream({ url: trimmed });
|
||||
if (typeof encoded === 'string') {
|
||||
window.location.hash = `#/player/${encodeURIComponent(encoded)}`;
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to encode stream:', e);
|
||||
}
|
||||
toast.show({
|
||||
type: 'error',
|
||||
title: 'Failed to load HTTP stream.',
|
||||
timeout: 5000
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = magnet.decode(trimmed);
|
||||
if (parsed && typeof parsed.infoHash === 'string') {
|
||||
const serverReady = streamingServer.settings !== null
|
||||
&& streamingServer.settings.type === 'Ready';
|
||||
if (!serverReady) {
|
||||
toast.show({
|
||||
type: 'error',
|
||||
title: 'Streaming server is not available. Cannot play magnet links.',
|
||||
timeout: 5000
|
||||
});
|
||||
return false;
|
||||
}
|
||||
createTorrentFromMagnet(trimmed);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [streamingServer.settings, createTorrentFromMagnet]);
|
||||
|
||||
return { handlePlayUrl };
|
||||
};
|
||||
|
||||
export default usePlayUrl;
|
||||
|
|
@ -6,14 +6,22 @@ const { useServices } = require('stremio/services');
|
|||
const useToast = require('stremio/common/Toast/useToast');
|
||||
const useStreamingServer = require('stremio/common/useStreamingServer');
|
||||
|
||||
const CREATE_TORRENT_TIMEOUT = 20000;
|
||||
|
||||
const useTorrent = () => {
|
||||
const { core } = useServices();
|
||||
const streamingServer = useStreamingServer();
|
||||
const toast = useToast();
|
||||
const createTorrentTimeout = React.useRef(null);
|
||||
const parsingToastId = React.useRef(null);
|
||||
const createTorrentFromMagnet = React.useCallback((text) => {
|
||||
const parsed = magnet.decode(text);
|
||||
if (parsed && typeof parsed.infoHash === 'string') {
|
||||
parsingToastId.current = toast.show({
|
||||
type: 'success',
|
||||
title: 'Loading magnet link…',
|
||||
timeout: CREATE_TORRENT_TIMEOUT
|
||||
});
|
||||
core.transport.dispatch({
|
||||
action: 'StreamingServer',
|
||||
args: {
|
||||
|
|
@ -23,12 +31,13 @@ const useTorrent = () => {
|
|||
});
|
||||
clearTimeout(createTorrentTimeout.current);
|
||||
createTorrentTimeout.current = setTimeout(() => {
|
||||
toast.remove(parsingToastId.current);
|
||||
toast.show({
|
||||
type: 'error',
|
||||
title: 'It\'s taking a long time to get metadata from the torrent.',
|
||||
timeout: 10000
|
||||
title: 'Failed to parse magnet link.',
|
||||
timeout: 8000
|
||||
});
|
||||
}, 10000);
|
||||
}, CREATE_TORRENT_TIMEOUT);
|
||||
}
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
|
|
@ -36,6 +45,7 @@ const useTorrent = () => {
|
|||
const [, { type }] = streamingServer.torrent;
|
||||
if (type === 'Ready') {
|
||||
clearTimeout(createTorrentTimeout.current);
|
||||
toast.remove(parsingToastId.current);
|
||||
}
|
||||
}
|
||||
}, [streamingServer.torrent]);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
@width-mobile: 3rem;
|
||||
|
||||
|
||||
.ratings-container {
|
||||
.group-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
border-radius: 2rem;
|
||||
height: @height;
|
||||
width: fit-content;
|
||||
backdrop-filter: blur(5px);
|
||||
|
||||
.icon-container {
|
||||
display: flex;
|
||||
|
|
@ -45,7 +46,7 @@
|
|||
}
|
||||
|
||||
@media @phone-landscape {
|
||||
.ratings-container {
|
||||
.group-container {
|
||||
height: @height-mobile;
|
||||
|
||||
.icon-container {
|
||||
45
src/components/ActionsGroup/ActionsGroup.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import Icon from '@stremio/stremio-icons/react';
|
||||
import { Tooltip } from 'stremio/common/Tooltips';
|
||||
import styles from './ActionsGroup.less';
|
||||
|
||||
type Item = {
|
||||
icon: string;
|
||||
label?: string;
|
||||
filled?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
items: Item[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const ActionsGroup = ({ items, className }: Props) => {
|
||||
return (
|
||||
<div className={classNames(styles['group-container'], className)}>
|
||||
{
|
||||
items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={classNames(styles['icon-container'], item.className, { [styles['disabled']]: item.disabled })}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{
|
||||
item.label &&
|
||||
<Tooltip label={item.label} position={'top'} />
|
||||
}
|
||||
<Icon name={item.icon} className={styles['icon']} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionsGroup;
|
||||
6
src/components/ActionsGroup/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import ActionsGroup from './ActionsGroup';
|
||||
|
||||
export default ActionsGroup;
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@
|
|||
}
|
||||
|
||||
&.error {
|
||||
border-color: var(--color-trakt);
|
||||
border-color: var(--danger-accent-color);
|
||||
}
|
||||
|
||||
&.checked {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,5 +1,6 @@
|
|||
import React, { memo, RefObject, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Transition from '../Transition';
|
||||
import styles from './ContextMenu.less';
|
||||
|
||||
const PADDING = 8;
|
||||
|
|
@ -47,7 +48,6 @@ const ContextMenu = ({ children, on, autoClose }: Props) => {
|
|||
}, [position, containerSize]);
|
||||
|
||||
const close = () => {
|
||||
setPosition([0, 0]);
|
||||
setActive(false);
|
||||
};
|
||||
|
||||
|
|
@ -78,23 +78,25 @@ const ContextMenu = ({ children, on, autoClose }: Props) => {
|
|||
};
|
||||
}, [on]);
|
||||
|
||||
return active && createPortal((
|
||||
<div
|
||||
className={styles['context-menu-container']}
|
||||
onMouseDown={close}
|
||||
onTouchStart={close}
|
||||
>
|
||||
return createPortal((
|
||||
<Transition when={active} name={'fade'}>
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles['context-menu']}
|
||||
style={style}
|
||||
onMouseDown={stopPropagation}
|
||||
onTouchStart={stopPropagation}
|
||||
onClick={onClick}
|
||||
className={styles['context-menu-container']}
|
||||
onMouseDown={close}
|
||||
onTouchStart={close}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles['context-menu']}
|
||||
style={style}
|
||||
onMouseDown={stopPropagation}
|
||||
onTouchStart={stopPropagation}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
), document.body);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,24 +5,11 @@ const PropTypes = require('prop-types');
|
|||
const { useServices } = require('stremio/services');
|
||||
const LibItem = require('stremio/components/LibItem');
|
||||
|
||||
const ContinueWatchingItem = ({ _id, notifications, deepLinks, ...props }) => {
|
||||
const ContinueWatchingItem = ({ _id, notifications, ...props }) => {
|
||||
const { core } = useServices();
|
||||
|
||||
const onClick = React.useCallback(() => {
|
||||
if (deepLinks?.metaDetailsVideos ?? deepLinks?.metaDetailsStreams) {
|
||||
window.location = deepLinks?.metaDetailsVideos ?? deepLinks?.metaDetailsStreams;
|
||||
}
|
||||
}, [deepLinks]);
|
||||
|
||||
const onPlayClick = React.useCallback((event) => {
|
||||
event.stopPropagation();
|
||||
if (deepLinks?.player ?? deepLinks?.metaDetailsStreams ?? deepLinks?.metaDetailsVideos) {
|
||||
window.location = deepLinks?.player ?? deepLinks?.metaDetailsStreams ?? deepLinks?.metaDetailsVideos;
|
||||
}
|
||||
}, [deepLinks]);
|
||||
|
||||
const onDismissClick = React.useCallback((event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
if (typeof _id === 'string') {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
|
|
@ -47,8 +34,6 @@ const ContinueWatchingItem = ({ _id, notifications, deepLinks, ...props }) => {
|
|||
_id={_id}
|
||||
posterChangeCursor={true}
|
||||
notifications={notifications}
|
||||
onClick={onClick}
|
||||
onPlayClick={onPlayClick}
|
||||
onDismissClick={onDismissClick}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ type Props = {
|
|||
src: string,
|
||||
alt: string,
|
||||
fallbackSrc: string,
|
||||
renderFallback: () => void,
|
||||
renderFallback: () => React.ReactNode,
|
||||
onError: (event: React.SyntheticEvent<HTMLImageElement>) => void,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const LibItem = ({ _id, removable, notifications, watched, ...props }) => {
|
|||
case 'details':
|
||||
return props.deepLinks && (typeof props.deepLinks.metaDetailsVideos === 'string' || typeof props.deepLinks.metaDetailsStreams === 'string');
|
||||
case 'watched':
|
||||
return props.deepLinks && (typeof props.deepLinks.metaDetailsVideos === 'string' || typeof props.deepLinks.metaDetailsStreams === 'string');
|
||||
return typeof watched !== 'undefined' && props.deepLinks && (typeof props.deepLinks.metaDetailsVideos === 'string' || typeof props.deepLinks.metaDetailsStreams === 'string');
|
||||
case 'dismiss':
|
||||
return typeof _id === 'string' && props.progress !== null && !isNaN(props.progress) && props.progress > 0;
|
||||
case 'remove':
|
||||
|
|
@ -119,6 +119,16 @@ const LibItem = ({ _id, removable, notifications, watched, ...props }) => {
|
|||
}
|
||||
}, [_id, props.deepLinks, props.optionOnSelect]);
|
||||
|
||||
const onPlayClick = React.useMemo(() => {
|
||||
if (props.deepLinks && typeof props.deepLinks.player === 'string') {
|
||||
return (event) => {
|
||||
event.preventDefault();
|
||||
window.location = props.deepLinks.player;
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}, [props.deepLinks]);
|
||||
|
||||
return (
|
||||
<MetaItem
|
||||
{...props}
|
||||
|
|
@ -126,6 +136,7 @@ const LibItem = ({ _id, removable, notifications, watched, ...props }) => {
|
|||
newVideos={newVideos}
|
||||
options={options}
|
||||
optionOnSelect={optionOnSelect}
|
||||
onPlayClick={onPlayClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -18,14 +18,14 @@ const MetaItem = React.memo(({ className, type, name, poster, posterShape, poste
|
|||
const [menuOpen, onMenuOpen, onMenuClose] = useBinaryState(false);
|
||||
const href = React.useMemo(() => {
|
||||
return deepLinks ?
|
||||
typeof deepLinks.player === 'string' ?
|
||||
deepLinks.player
|
||||
typeof deepLinks.metaDetailsStreams === 'string' ?
|
||||
deepLinks.metaDetailsStreams
|
||||
:
|
||||
typeof deepLinks.metaDetailsStreams === 'string' ?
|
||||
deepLinks.metaDetailsStreams
|
||||
typeof deepLinks.metaDetailsVideos === 'string' ?
|
||||
deepLinks.metaDetailsVideos
|
||||
:
|
||||
typeof deepLinks.metaDetailsVideos === 'string' ?
|
||||
deepLinks.metaDetailsVideos
|
||||
typeof deepLinks.player === 'string' ?
|
||||
deepLinks.player
|
||||
:
|
||||
null
|
||||
:
|
||||
|
|
|
|||
|
|
@ -66,6 +66,15 @@
|
|||
|
||||
.icon-container {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const { useTranslation } = require('react-i18next');
|
|||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { default: Button } = require('stremio/components/Button');
|
||||
const { default: Image } = require('stremio/components/Image');
|
||||
const { default: ActionsGroup } = require('stremio/components/ActionsGroup');
|
||||
const ModalDialog = require('stremio/components/ModalDialog');
|
||||
const SharePrompt = require('stremio/components/SharePrompt');
|
||||
const CONSTANTS = require('stremio/common/CONSTANTS');
|
||||
|
|
@ -25,7 +26,7 @@ const ALLOWED_LINK_REDIRECTS = [
|
|||
routesRegexp.metadetails.regexp
|
||||
];
|
||||
|
||||
const MetaPreview = React.forwardRef(({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary, ratingInfo }, ref) => {
|
||||
const MetaPreview = React.forwardRef(({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary, watched, toggleWatched, ratingInfo }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [shareModalOpen, openShareModal, closeShareModal] = useBinaryState(false);
|
||||
const linksGroups = React.useMemo(() => {
|
||||
|
|
@ -98,6 +99,18 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
|
|||
const renderLogoFallback = React.useCallback(() => (
|
||||
<div className={styles['logo-placeholder']}>{name}</div>
|
||||
), [name]);
|
||||
const metaItemActions = React.useMemo(() => [
|
||||
{
|
||||
icon: inLibrary ? 'remove-from-library' : 'add-to-library',
|
||||
label: inLibrary ? t('REMOVE_FROM_LIB') : t('ADD_TO_LIB'),
|
||||
onClick: typeof toggleInLibrary === 'function' ? toggleInLibrary : null,
|
||||
},
|
||||
{
|
||||
icon: watched ? 'eye-off' : 'eye',
|
||||
label: watched ? t('CTX_MARK_UNWATCHED') : t('CTX_MARK_WATCHED'),
|
||||
onClick: typeof toggleWatched === 'function' ? toggleWatched : undefined,
|
||||
},
|
||||
], [inLibrary, watched, toggleInLibrary, toggleWatched]);
|
||||
return (
|
||||
<div className={classnames(className, styles['meta-preview-container'], { [styles['compact']]: compact })} ref={ref}>
|
||||
{
|
||||
|
|
@ -195,19 +208,6 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
|
|||
}
|
||||
</div>
|
||||
<div className={styles['action-buttons-container']}>
|
||||
{
|
||||
typeof toggleInLibrary === 'function' ?
|
||||
<ActionButton
|
||||
className={styles['action-button']}
|
||||
icon={inLibrary ? 'remove-from-library' : 'add-to-library'}
|
||||
label={inLibrary ? t('REMOVE_FROM_LIB') : t('ADD_TO_LIB')}
|
||||
tooltip={compact}
|
||||
tabIndex={compact ? -1 : 0}
|
||||
onClick={toggleInLibrary}
|
||||
/>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
typeof trailerHref === 'string' ?
|
||||
<ActionButton
|
||||
|
|
@ -221,6 +221,11 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
|
|||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
typeof toggleInLibrary === 'function' && typeof toggleWatched === 'function'
|
||||
? <ActionsGroup items={metaItemActions} className={styles['group-container']} />
|
||||
: null
|
||||
}
|
||||
{
|
||||
typeof showHref === 'string' && compact ?
|
||||
<ActionButton
|
||||
|
|
@ -237,7 +242,7 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
|
|||
!compact && ratingInfo !== null ?
|
||||
<Ratings
|
||||
ratingInfo={ratingInfo}
|
||||
className={styles['ratings']}
|
||||
className={styles['group-container']}
|
||||
/>
|
||||
:
|
||||
null
|
||||
|
|
@ -298,6 +303,8 @@ MetaPreview.propTypes = {
|
|||
trailerStreams: PropTypes.array,
|
||||
inLibrary: PropTypes.bool,
|
||||
toggleInLibrary: PropTypes.func,
|
||||
watched: PropTypes.bool,
|
||||
toggleWatched: PropTypes.func,
|
||||
ratingInfo: PropTypes.object,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@
|
|||
|
||||
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';
|
||||
import { ActionsGroup } from 'stremio/components';
|
||||
|
||||
type Props = {
|
||||
metaId?: string;
|
||||
|
|
@ -16,15 +14,21 @@ const Ratings = ({ ratingInfo, className }: Props) => {
|
|||
const { onLiked, onLoved, liked, loved } = useRating(ratingInfo);
|
||||
const disabled = useMemo(() => ratingInfo?.type !== 'Ready', [ratingInfo]);
|
||||
|
||||
const items = useMemo(() => [
|
||||
{
|
||||
icon: liked ? 'thumbs-up' : 'thumbs-up-outline',
|
||||
disabled,
|
||||
onClick: onLiked,
|
||||
},
|
||||
{
|
||||
icon: loved ? 'heart' : 'heart-outline',
|
||||
disabled,
|
||||
onClick: onLoved,
|
||||
},
|
||||
], [liked, loved, disabled]);
|
||||
|
||||
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>
|
||||
<ActionsGroup items={items} className={className} />
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
.action-buttons-container {
|
||||
justify-content: space-between;
|
||||
|
||||
.action-button:not(:last-child) {
|
||||
.action-button:not(:last-child), .group-container:not(:last-child) {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -207,11 +207,20 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group-container {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.ratings {
|
||||
margin-bottom: 1rem;
|
||||
margin-right: 1rem;
|
||||
&:global(.wide) {
|
||||
width: auto;
|
||||
padding: 0 2rem;
|
||||
border-radius: 4rem;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -233,17 +242,13 @@
|
|||
padding-top: 1.5rem;
|
||||
gap: 0.5rem;
|
||||
|
||||
.action-button {
|
||||
.action-button, .group-container {
|
||||
padding: 0 1.5rem !important;
|
||||
margin-right: 0rem !important;
|
||||
height: 3rem;
|
||||
border-radius: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ratings {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -272,6 +277,10 @@
|
|||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 0 1rem !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const HorizontalNavBar = React.memo(({ className, route, query, title, backButto
|
|||
<div className={styles['logo-container']}>
|
||||
<Image
|
||||
className={styles['logo']}
|
||||
src={require('/images/stremio_symbol.png')}
|
||||
src={require('/assets/images/stremio_symbol.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ const { Button } = require('stremio/components');
|
|||
const { default: useFullscreen } = require('stremio/common/useFullscreen');
|
||||
const useProfile = require('stremio/common/useProfile');
|
||||
const usePWA = require('stremio/common/usePWA');
|
||||
const useTorrent = require('stremio/common/useTorrent');
|
||||
const { default: usePlayUrl } = require('stremio/common/usePlayUrl');
|
||||
const useToast = require('stremio/common/Toast/useToast');
|
||||
const { withCoreSuspender } = require('stremio/common/CoreSuspender');
|
||||
const useStreamingServer = require('stremio/common/useStreamingServer');
|
||||
const styles = require('./styles');
|
||||
|
|
@ -20,7 +21,8 @@ const NavMenuContent = ({ onClick }) => {
|
|||
const { core } = useServices();
|
||||
const profile = useProfile();
|
||||
const streamingServer = useStreamingServer();
|
||||
const { createTorrentFromMagnet } = useTorrent();
|
||||
const { handlePlayUrl } = usePlayUrl();
|
||||
const toast = useToast();
|
||||
const [fullscreen, requestFullscreen, exitFullscreen] = useFullscreen();
|
||||
const [isIOSPWA, isAndroidPWA] = usePWA();
|
||||
const streamingServerWarningDismissed = React.useMemo(() => {
|
||||
|
|
@ -40,11 +42,18 @@ const NavMenuContent = ({ onClick }) => {
|
|||
const onPlayMagnetLinkClick = React.useCallback(async () => {
|
||||
try {
|
||||
const clipboardText = await navigator.clipboard.readText();
|
||||
createTorrentFromMagnet(clipboardText);
|
||||
const handled = await handlePlayUrl(clipboardText);
|
||||
if (!handled) {
|
||||
toast.show({
|
||||
type: 'error',
|
||||
title: 'Clipboard does not contain a valid URL or magnet link.',
|
||||
timeout: 5000
|
||||
});
|
||||
}
|
||||
} catch(e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, []);
|
||||
}, [handlePlayUrl]);
|
||||
return (
|
||||
<div className={classnames(styles['nav-menu-container'], 'animation-fade-in', { [styles['with-warning']]: !streamingServerWarningDismissed } )} onClick={onClick}>
|
||||
<div className={styles['user-info-container']}>
|
||||
|
|
@ -52,12 +61,12 @@ const NavMenuContent = ({ onClick }) => {
|
|||
className={styles['avatar-container']}
|
||||
style={{
|
||||
backgroundImage: profile.auth === null ?
|
||||
`url('${require('/images/anonymous.png')}')`
|
||||
`url('${require('/assets/images/anonymous.png')}')`
|
||||
:
|
||||
profile.auth.user.avatar ?
|
||||
`url('${profile.auth.user.avatar}')`
|
||||
:
|
||||
`url('${require('/images/default_avatar.png')}')`
|
||||
`url('${require('/assets/images/default_avatar.png')}')`
|
||||
}}
|
||||
/>
|
||||
<div className={styles['user-info-details']}>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const { default: Icon } = require('@stremio/stremio-icons/react');
|
|||
const { useRouteFocused } = require('stremio-router');
|
||||
const Button = require('stremio/components/Button').default;
|
||||
const TextInput = require('stremio/components/TextInput').default;
|
||||
const useTorrent = require('stremio/common/useTorrent');
|
||||
const { default: usePlayUrl } = require('stremio/common/usePlayUrl');
|
||||
const { withCoreSuspender } = require('stremio/common/CoreSuspender');
|
||||
const useSearchHistory = require('./useSearchHistory');
|
||||
const useLocalSearch = require('./useLocalSearch');
|
||||
|
|
@ -21,7 +21,7 @@ const SearchBar = React.memo(({ className, query, active }) => {
|
|||
const routeFocused = useRouteFocused();
|
||||
const searchHistory = useSearchHistory();
|
||||
const localSearch = useLocalSearch();
|
||||
const { createTorrentFromMagnet } = useTorrent();
|
||||
const { handlePlayUrl } = usePlayUrl();
|
||||
|
||||
const [historyOpen, openHistory, closeHistory, ] = useBinaryState(query === null ? true : false);
|
||||
const [currentQuery, setCurrentQuery] = React.useState(query || '');
|
||||
|
|
@ -52,12 +52,14 @@ const SearchBar = React.memo(({ className, query, active }) => {
|
|||
const value = searchInputRef.current.value;
|
||||
setCurrentQuery(value);
|
||||
openHistory();
|
||||
try {
|
||||
createTorrentFromMagnet(value);
|
||||
} catch (error) {
|
||||
console.error('Failed to create torrent from magnet:', error);
|
||||
}, []);
|
||||
|
||||
const queryInputOnPaste = React.useCallback((event) => {
|
||||
const pasted = event.clipboardData.getData('text');
|
||||
if (pasted) {
|
||||
handlePlayUrl(pasted);
|
||||
}
|
||||
}, [createTorrentFromMagnet]);
|
||||
}, [handlePlayUrl]);
|
||||
|
||||
const queryInputOnSubmit = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
|
|
@ -108,6 +110,7 @@ const SearchBar = React.memo(({ className, query, active }) => {
|
|||
defaultValue={query}
|
||||
tabIndex={-1}
|
||||
onChange={queryInputOnChange}
|
||||
onPaste={queryInputOnPaste}
|
||||
onSubmit={queryInputOnSubmit}
|
||||
onClick={openHistory}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@
|
|||
}
|
||||
|
||||
&.error {
|
||||
border-color: var(--color-trakt);
|
||||
border-color: var(--danger-accent-color);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
|
|
|
|||
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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
import Keys from './Keys';
|
||||
export default Keys;
|
||||
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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||