Compare commits
248 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 | ||
|
|
e8bee4997a | ||
|
|
71e0bb4481 | ||
|
|
6bf3b8147d | ||
|
|
f73fa5931e | ||
|
|
a9d9c8d808 | ||
|
|
c70211153e | ||
|
|
5eb55d3aaf | ||
|
|
e1e6fe075b | ||
|
|
9ccc6b8271 | ||
|
|
67f4f349bb | ||
|
|
97c3b7d004 | ||
|
|
373ccf351a | ||
|
|
987201edd3 | ||
|
|
ff08e377fc | ||
|
|
3b2d1f365c | ||
|
|
852f478f1e | ||
|
|
6833bb719d | ||
|
|
2de2e89446 | ||
|
|
ea69521912 | ||
|
|
56989781c8 | ||
|
|
5ab324f125 | ||
|
|
ea5d05c31d | ||
|
|
881c808003 | ||
|
|
d2db62f33a | ||
|
|
3b730a2bd8 |
4
.github/workflows/auto_assign.yml
vendored
|
|
@ -14,7 +14,7 @@ jobs:
|
|||
# Auto assign PR to author
|
||||
- name: Auto Assign PR to Author
|
||||
if: github.event.pull_request.head.repo.fork == false && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
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: |
|
||||
|
|
|
|||
2
.github/workflows/build.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
|
|
|||
4
.github/workflows/release.yml
vendored
|
|
@ -11,7 +11,7 @@ jobs:
|
|||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
- 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.3
|
||||
uses: svenstaro/upload-release-action@2.11.5
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: stremio-web.zip
|
||||
|
|
|
|||
|
|
@ -41,15 +41,15 @@ docker run -p 8080:8080 stremio-web
|
|||
|
||||
### 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 |
|
Before Width: | Height: | Size: 1.6 MiB 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 |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
13
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "stremio",
|
||||
"displayName": "Stremio",
|
||||
"version": "5.0.0-beta.29",
|
||||
"version": "5.0.0-beta.34",
|
||||
"author": "Smart Code OOD",
|
||||
"private": true,
|
||||
"license": "gpl-2.0",
|
||||
|
|
@ -17,21 +17,21 @@
|
|||
"@babel/runtime": "7.26.0",
|
||||
"@sentry/browser": "8.42.0",
|
||||
"@stremio/stremio-colors": "5.2.0",
|
||||
"@stremio/stremio-core-web": "0.51.1",
|
||||
"@stremio/stremio-core-web": "0.56.4",
|
||||
"@stremio/stremio-icons": "5.8.0",
|
||||
"@stremio/stremio-video": "0.0.64",
|
||||
"@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#01aaa201e419782b26b9f2cbe4430795021426e5",
|
||||
"stremio-translations": "github:Stremio/stremio-translations#90ea718c18750a0e9cd6824b0ef7c512a41cb90b",
|
||||
"url": "0.11.4",
|
||||
"use-long-press": "^3.2.0"
|
||||
},
|
||||
|
|
@ -53,12 +53,10 @@
|
|||
"@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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1038
pnpm-lock.yaml
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const ServicesToaster = () => {
|
|||
}
|
||||
case 'MagnetParsed': {
|
||||
toast.show({
|
||||
type: 'success',
|
||||
type: 'info',
|
||||
title: 'Magnet link parsed',
|
||||
timeout: 4000
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import React, { createContext, useCallback, useContext, useEffect } from 'react';
|
||||
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 = () => void;
|
||||
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);
|
||||
|
|
@ -18,27 +20,38 @@ type Props = {
|
|||
};
|
||||
|
||||
const ShortcutsProvider = ({ children, onShortcut }: Props) => {
|
||||
const onKeyDown = useCallback(({ ctrlKey, shiftKey, key }: KeyboardEvent) => {
|
||||
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(key.toUpperCase())) {
|
||||
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);
|
||||
};
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [onKeyDown]);
|
||||
|
||||
return (
|
||||
<ShortcutsContext.Provider value={{ grouped: shortcuts }}>
|
||||
<ShortcutsContext.Provider value={{ grouped: shortcuts, on, off }}>
|
||||
{children}
|
||||
</ShortcutsContext.Provider>
|
||||
);
|
||||
|
|
@ -50,5 +63,5 @@ const useShortcuts = () => {
|
|||
|
||||
export {
|
||||
ShortcutsProvider,
|
||||
useShortcuts
|
||||
useShortcuts,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +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;
|
||||
|
|
@ -59,6 +59,11 @@
|
|||
"label": "SETTINGS_SHORTCUT_VOLUME_DOWN",
|
||||
"combos": [["ArrowDown"]]
|
||||
},
|
||||
{
|
||||
"name": "mute",
|
||||
"label": "SETTINGS_SHORTCUT_MUTE",
|
||||
"combos": [["M"]]
|
||||
},
|
||||
{
|
||||
"name": "subtitlesSize",
|
||||
"label": "SETTINGS_SHORTCUT_SUBTITLES_SIZE",
|
||||
|
|
@ -69,6 +74,21 @@
|
|||
"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",
|
||||
|
|
@ -83,6 +103,21 @@
|
|||
"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"]]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,8 +4,7 @@ const { FileDropProvider, onFileDrop } = require('./FileDrop');
|
|||
const { PlatformProvider, usePlatform } = require('./Platform');
|
||||
const { ToastProvider, useToast } = require('./Toast');
|
||||
const { TooltipProvider, Tooltip } = require('./Tooltips');
|
||||
const { ShortcutsProvider, useShortcuts } = require('./Shortcuts');
|
||||
const comparatorWithPriorities = require('./comparatorWithPriorities');
|
||||
const { ShortcutsProvider, useShortcuts, onShortcut } = require('./Shortcuts');
|
||||
const CONSTANTS = require('./CONSTANTS');
|
||||
const { withCoreSuspender, useCoreSuspender } = require('./CoreSuspender');
|
||||
const getVisibleChildrenRange = require('./getVisibleChildrenRange');
|
||||
|
|
@ -26,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');
|
||||
|
|
@ -38,11 +38,11 @@ module.exports = {
|
|||
usePlatform,
|
||||
ShortcutsProvider,
|
||||
useShortcuts,
|
||||
onShortcut,
|
||||
ToastProvider,
|
||||
useToast,
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
comparatorWithPriorities,
|
||||
CONSTANTS,
|
||||
withCoreSuspender,
|
||||
useCoreSuspender,
|
||||
|
|
@ -64,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"]
|
||||
|
|
@ -91,6 +99,10 @@
|
|||
"name": "한국어",
|
||||
"codes": ["ko-KR", "kor"]
|
||||
},
|
||||
{
|
||||
"name": "Lietuvių",
|
||||
"codes": ["lt-LT", "ltu"]
|
||||
},
|
||||
{
|
||||
"name": "македонски јазик",
|
||||
"codes": ["mk-MK", "mkd"]
|
||||
|
|
@ -99,6 +111,10 @@
|
|||
"name": "ဗမာစာ",
|
||||
"codes": ["my-BM", "mya"]
|
||||
},
|
||||
{
|
||||
"name": "नेपाली",
|
||||
"codes": ["ne-NP", "nep"]
|
||||
},
|
||||
{
|
||||
"name": "Norsk bokmål",
|
||||
"codes": ["nb-NO", "nob"]
|
||||
|
|
@ -111,6 +127,10 @@
|
|||
"name": "Norsk nynorsk",
|
||||
"codes": ["nn-NO", "nno"]
|
||||
},
|
||||
{
|
||||
"name": "ਪੰਜਾਬੀ",
|
||||
"codes": ["pa-IN", "pan"]
|
||||
},
|
||||
{
|
||||
"name": "język polski",
|
||||
"codes": ["pl-PL", "pol"]
|
||||
|
|
@ -151,6 +171,10 @@
|
|||
"name": "తెలుగు",
|
||||
"codes": ["te-IN", "tel"]
|
||||
},
|
||||
{
|
||||
"name": "தமிழ்",
|
||||
"codes": ["tl-TM", "tam"]
|
||||
},
|
||||
{
|
||||
"name": "Türkçe",
|
||||
"codes": ["tr-TR", "tur"]
|
||||
|
|
@ -159,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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}, {});
|
||||
},
|
||||
|
|
|
|||
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;
|
||||
|
||||
|
|
@ -70,7 +70,7 @@
|
|||
}
|
||||
|
||||
&.error {
|
||||
border-color: var(--color-trakt);
|
||||
border-color: var(--danger-accent-color);
|
||||
}
|
||||
|
||||
&.checked {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
:
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ const MetaLinks = ({ className, label, links }) => {
|
|||
{
|
||||
typeof label === 'string' && label.length > 0 ?
|
||||
<div className={styles['label-container']}>
|
||||
{ stringWithPrefix(label.toUpperCase(), 'LINKS') }
|
||||
{ stringWithPrefix(label.toUpperCase(), 'LINKS_') }
|
||||
</div>
|
||||
:
|
||||
null
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ type Props = {
|
|||
children: JSX.Element,
|
||||
when: boolean,
|
||||
name: string,
|
||||
duration?: number,
|
||||
};
|
||||
|
||||
const Transition = ({ children, when, name }: Props) => {
|
||||
const Transition = ({ children, when, name, duration }: Props) => {
|
||||
const [element, setElement] = useState<HTMLElement | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
|
|
@ -29,6 +30,10 @@ const Transition = ({ children, when, name }: Props) => {
|
|||
);
|
||||
}, [name, state, active, children]);
|
||||
|
||||
const style = useMemo(() => {
|
||||
if (duration) return { transitionDuration: `${duration}ms` };
|
||||
}, [duration]);
|
||||
|
||||
const onTransitionEnd = useCallback(() => {
|
||||
state === 'exit' && setMounted(false);
|
||||
}, [state]);
|
||||
|
|
@ -39,9 +44,10 @@ const Transition = ({ children, when, name }: Props) => {
|
|||
}, [when]);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const animationFrame = requestAnimationFrame(() => {
|
||||
setActive(!!element);
|
||||
});
|
||||
return () => cancelAnimationFrame(animationFrame);
|
||||
}, [element]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -53,6 +59,7 @@ const Transition = ({ children, when, name }: Props) => {
|
|||
mounted && cloneElement(children, {
|
||||
ref: callbackRef,
|
||||
className,
|
||||
style,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
border: 0.15rem solid transparent;
|
||||
|
||||
@supports (scroll-margin: 1.25rem) {
|
||||
scroll-margin: 1.25rem;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import TextInput from './TextInput';
|
|||
import Toggle from './Toggle';
|
||||
import Transition from './Transition';
|
||||
import Video from './Video';
|
||||
import ActionsGroup from './ActionsGroup';
|
||||
|
||||
export {
|
||||
AddonDetailsModal,
|
||||
|
|
@ -65,4 +66,5 @@ export {
|
|||
Toggle,
|
||||
Transition,
|
||||
Video,
|
||||
ActionsGroup
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=0, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Stremio">
|
||||
<link rel="icon" type="image/x-icon" href="<%= htmlWebpackPlugin.options.faviconsPath %>/favicon.ico">
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<title>Stremio - Freedom to Stream</title>
|
||||
<%= htmlWebpackPlugin.tags.headTags %>
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ const ReactIs = require('react-is');
|
|||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const UrlUtils = require('url');
|
||||
const isEqual = require('lodash.isequal');
|
||||
const { deepEqual } = require('fast-equals');
|
||||
const { RouteFocusedProvider } = require('../RouteFocusedContext');
|
||||
const Route = require('../Route');
|
||||
const routeConfigForPath = require('./routeConfigForPath');
|
||||
|
|
@ -54,11 +54,11 @@ const Router = ({ className, onPathNotMatch, onRouteChange, ...props }) => {
|
|||
return {
|
||||
key: `${routeViewIndex}${routeIndex}`,
|
||||
component: routeConfig.component,
|
||||
urlParams: view !== null && isEqual(view.urlParams, urlParams) ?
|
||||
urlParams: view !== null && deepEqual(view.urlParams, urlParams) ?
|
||||
view.urlParams
|
||||
:
|
||||
urlParams,
|
||||
queryParams: view !== null && isEqual(Array.from(view.queryParams.entries()), Array.from(queryParams.entries())) ?
|
||||
queryParams: view !== null && deepEqual(Array.from(view.queryParams.entries()), Array.from(queryParams.entries())) ?
|
||||
view.queryParams
|
||||
:
|
||||
queryParams
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const { default: Icon } = require('@stremio/stremio-icons/react');
|
|||
const { usePlatform, useBinaryState, withCoreSuspender } = require('stremio/common');
|
||||
const { AddonDetailsModal, Button, Image, MainNavBars, ModalDialog, SearchBar, SharePrompt, TextInput, MultiselectMenu } = require('stremio/components');
|
||||
const { useServices } = require('stremio/services');
|
||||
const useToast = require('stremio/common/Toast/useToast');
|
||||
const Addon = require('./Addon');
|
||||
const useInstalledAddons = require('./useInstalledAddons');
|
||||
const useRemoteAddons = require('./useRemoteAddons');
|
||||
|
|
@ -20,6 +21,7 @@ const Addons = ({ urlParams, queryParams }) => {
|
|||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const { core } = useServices();
|
||||
const toast = useToast();
|
||||
const installedAddons = useInstalledAddons(urlParams);
|
||||
const remoteAddons = useRemoteAddons(urlParams);
|
||||
const [addonDetailsTransportUrl, setAddonDetailsTransportUrl] = useAddonDetailsTransportUrl(urlParams, queryParams);
|
||||
|
|
@ -29,7 +31,17 @@ const Addons = ({ urlParams, queryParams }) => {
|
|||
const addAddonUrlInputRef = React.useRef(null);
|
||||
const addAddonOnSubmit = React.useCallback(() => {
|
||||
if (addAddonUrlInputRef.current !== null) {
|
||||
setAddonDetailsTransportUrl(addAddonUrlInputRef.current.value);
|
||||
try {
|
||||
let url = new URL(addAddonUrlInputRef.current.value).toString();
|
||||
setAddonDetailsTransportUrl(url);
|
||||
} catch (e) {
|
||||
toast.show({
|
||||
type: 'error',
|
||||
title: `Failed to parse addon url: ${addAddonUrlInputRef.current.value}`,
|
||||
timeout: 10000
|
||||
});
|
||||
console.error('Failed to parse addon url:', e);
|
||||
}
|
||||
}
|
||||
}, [setAddonDetailsTransportUrl]);
|
||||
const addAddonModalButtons = React.useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const mapSelectableInputs = (installedAddons, remoteAddons, t) => {
|
|||
() => {
|
||||
const selectableCatalog = remoteAddons.selectable.catalogs
|
||||
.find(({ id }) => id === remoteAddons.selected.request.path.id);
|
||||
return selectableCatalog ? t.stringWithPrefix(selectableCatalog.name, 'ADDON_') : remoteAddons.selected.request.path.id;
|
||||
return selectableCatalog ? t.stringWithPrefix(selectableCatalog.name.toUpperCase(), 'ADDON_') : remoteAddons.selected.request.path.id;
|
||||
}
|
||||
: null,
|
||||
onSelect: (value) => {
|
||||
|
|
@ -50,7 +50,7 @@ const mapSelectableInputs = (installedAddons, remoteAddons, t) => {
|
|||
remoteAddons.selected !== null ?
|
||||
t.stringWithPrefix(remoteAddons.selected.request.path.type, 'TYPE_')
|
||||
:
|
||||
typeSelect.title;
|
||||
t.string('SELECT_TYPE');
|
||||
},
|
||||
onSelect: (value) => {
|
||||
window.location = value;
|
||||
|
|
|
|||
|
|
@ -22,11 +22,10 @@ const Board = () => {
|
|||
const profile = useProfile();
|
||||
const boardCatalogsOffset = continueWatchingPreview.items.length > 0 ? 1 : 0;
|
||||
const scrollContainerRef = React.useRef();
|
||||
const streamingServerWarningDismissed = React.useMemo(() => {
|
||||
return streamingServer.settings !== null && streamingServer.settings.type === 'Ready' || (
|
||||
!isNaN(profile.settings.streamingServerWarningDismissed.getTime()) &&
|
||||
profile.settings.streamingServerWarningDismissed.getTime() > Date.now()
|
||||
);
|
||||
const showStreamingServerWarning = React.useMemo(() => {
|
||||
return streamingServer.settings !== null && streamingServer.settings.type === 'Err' && (
|
||||
isNaN(profile.settings.streamingServerWarningDismissed.getTime()) ||
|
||||
profile.settings.streamingServerWarningDismissed.getTime() < Date.now());
|
||||
}, [profile.settings, streamingServer.settings]);
|
||||
const onVisibleRangeChange = React.useCallback(() => {
|
||||
const range = getVisibleChildrenRange(scrollContainerRef.current);
|
||||
|
|
@ -103,7 +102,7 @@ const Board = () => {
|
|||
</div>
|
||||
</MainNavBars>
|
||||
{
|
||||
!streamingServerWarningDismissed ?
|
||||
showStreamingServerWarning ?
|
||||
<StreamingServerWarning className={styles['board-warning-container']} />
|
||||
:
|
||||
null
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const Placeholder = () => {
|
|||
<div className={styles['image-container']}>
|
||||
<Image
|
||||
className={styles['image']}
|
||||
src={require('/images/calendar_placeholder.png')}
|
||||
src={require('/assets/images/calendar_placeholder.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,11 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
const [addonModalOpen, openAddonModal, closeAddonModal] = useBinaryState(false);
|
||||
const [selectedMetaItemIndex, setSelectedMetaItemIndex] = React.useState(0);
|
||||
|
||||
const selectedMetaItem = React.useMemo(() => {
|
||||
return discover.catalog?.content.type === 'Ready' &&
|
||||
discover.catalog.content.content[selectedMetaItemIndex] || null;
|
||||
}, [discover.catalog, selectedMetaItemIndex]);
|
||||
|
||||
const metasContainerRef = React.useRef();
|
||||
const metaPreviewRef = React.useRef();
|
||||
|
||||
|
|
@ -40,14 +45,6 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
}
|
||||
}
|
||||
}, [hasNextPage, loadNextPage]);
|
||||
const selectedMetaItem = React.useMemo(() => {
|
||||
return discover.catalog !== null &&
|
||||
discover.catalog.content.type === 'Ready' &&
|
||||
discover.catalog.content.content[selectedMetaItemIndex] ?
|
||||
discover.catalog.content.content[selectedMetaItemIndex]
|
||||
:
|
||||
null;
|
||||
}, [discover.catalog, selectedMetaItemIndex]);
|
||||
const addToLibrary = React.useCallback(() => {
|
||||
if (selectedMetaItem === null) {
|
||||
return;
|
||||
|
|
@ -74,6 +71,22 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
}
|
||||
});
|
||||
}, [selectedMetaItem]);
|
||||
const toggleWatched = React.useCallback(() => {
|
||||
if (selectedMetaItem === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'MetaItemMarkAsWatched',
|
||||
args: {
|
||||
meta_item: selectedMetaItem,
|
||||
is_watched: !selectedMetaItem.watched,
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [selectedMetaItem]);
|
||||
const metaItemsOnFocusCapture = React.useCallback((event) => {
|
||||
if (event.target.dataset.index !== null && !isNaN(event.target.dataset.index)) {
|
||||
setSelectedMetaItemIndex(parseInt(event.target.dataset.index, 10));
|
||||
|
|
@ -133,14 +146,14 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
discover.catalog === null ?
|
||||
<DelayedRenderer delay={500}>
|
||||
<div className={styles['message-container']}>
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
|
||||
<div className={styles['message-label']}>{t('NO_CATALOG_SELECTED')}</div>
|
||||
</div>
|
||||
</DelayedRenderer>
|
||||
:
|
||||
discover.catalog.content.type === 'Err' ?
|
||||
<div className={styles['message-container']}>
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
|
||||
<div className={styles['message-label']}>{discover.catalog.content.content}</div>
|
||||
</div>
|
||||
:
|
||||
|
|
@ -193,6 +206,8 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
trailerStreams={selectedMetaItem.trailerStreams}
|
||||
inLibrary={selectedMetaItem.inLibrary}
|
||||
toggleInLibrary={selectedMetaItem.inLibrary ? removeFromLibrary : addToLibrary}
|
||||
watched={selectedMetaItem.watched}
|
||||
toggleWatched={toggleWatched}
|
||||
metaId={selectedMetaItem.id}
|
||||
like={selectedMetaItem.like}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -54,13 +54,13 @@ const mapSelectableInputs = (discover, t) => {
|
|||
value
|
||||
})
|
||||
})),
|
||||
value: JSON.stringify({
|
||||
value: selectedExtra ? JSON.stringify({
|
||||
href: selectedExtra.deepLinks.discover,
|
||||
value: selectedExtra.value,
|
||||
}),
|
||||
}) : undefined,
|
||||
title: options.some(({ selected, value }) => selected && value === null) ?
|
||||
() => t.string(name.toUpperCase())
|
||||
: t.string(selectedExtra.value),
|
||||
: selectedExtra ? t.string(selectedExtra.value) : () => t.string(name.toUpperCase()),
|
||||
onSelect: (value) => {
|
||||
const { href } = JSON.parse(value);
|
||||
window.location = href;
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ const Intro = ({ queryParams }) => {
|
|||
return;
|
||||
}
|
||||
if (!state.privacyPolicyAccepted) {
|
||||
dispatch({ type: 'error', error: 'You must accept the Privacy Policy' });
|
||||
dispatch({ type: 'error', error: t('MUST_ACCEPT_PRIVACY_POLICY') });
|
||||
return;
|
||||
}
|
||||
openLoaderModal();
|
||||
|
|
@ -296,7 +296,7 @@ const Intro = ({ queryParams }) => {
|
|||
<div className={styles['background-container']} />
|
||||
<div className={styles['heading-container']}>
|
||||
<div className={styles['logo-container']}>
|
||||
<Image className={styles['logo']} src={require('/images/logo.png')} alt={' '} />
|
||||
<Image className={styles['logo']} src={require('/assets/images/logo.png')} alt={' '} />
|
||||
</div>
|
||||
<div className={styles['title-container']}>
|
||||
{t('WEBSITE_SLOGAN_NEW_NEW')}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const PasswordResetModal = ({ email, onCloseRequest }) => {
|
|||
emailRef.current.value.length > 0 && emailRef.current.validity.valid ?
|
||||
platform.openExternal('https://www.strem.io/reset-password/' + emailRef.current.value, '_blank')
|
||||
:
|
||||
setError('Invalid email');
|
||||
setError(t('INVALID_EMAIL'));
|
||||
}, []);
|
||||
const passwordResetModalButtons = React.useMemo(() => {
|
||||
return [
|
||||
|
|
@ -31,7 +31,7 @@ const PasswordResetModal = ({ email, onCloseRequest }) => {
|
|||
}
|
||||
},
|
||||
{
|
||||
label: t('SEND'),
|
||||
label: t('BUTTON_SEND'),
|
||||
props: {
|
||||
onClick: goToPasswordReset
|
||||
}
|
||||
|
|
@ -52,7 +52,7 @@ const PasswordResetModal = ({ email, onCloseRequest }) => {
|
|||
ref={emailRef}
|
||||
className={styles['credentials-text-input']}
|
||||
type={'email'}
|
||||
placeholder={'Email'}
|
||||
placeholder={t('EMAIL')}
|
||||
defaultValue={typeof email === 'string' ? email : ''}
|
||||
onChange={emailOnChange}
|
||||
onSubmit={goToPasswordReset}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
bottom: -1rem;
|
||||
left: -1rem;
|
||||
right: -1rem;
|
||||
background: url('/images/background_1.svg'), url('/images/background_2.svg');
|
||||
background: url('/assets/images/background_1.svg'), url('/assets/images/background_2.svg');
|
||||
background-color: var(--primary-background-color);
|
||||
background-position: bottom left, top right;
|
||||
background-size: 53%, 54%;
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ const Library = ({ model, urlParams, queryParams }) => {
|
|||
<div className={styles['message-container']}>
|
||||
<Image
|
||||
className={styles['image']}
|
||||
src={require('/images/empty.png')}
|
||||
src={require('/assets/images/empty.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
<div className={styles['message-label']}>{model === 'library' ? t('LIBRARY_NOT_LOADED') : t('BOARD_CONTINUE_WATCHING_NOT_LOADED')}</div>
|
||||
|
|
@ -96,7 +96,7 @@ const Library = ({ model, urlParams, queryParams }) => {
|
|||
<div className={styles['message-container']}>
|
||||
<Image
|
||||
className={styles['image']}
|
||||
src={require('/images/empty.png')}
|
||||
src={require('/assets/images/empty.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
<div className={styles['message-label']}>{model === 'library' ? t('LIBRARY_EMPTY') : t('BOARD_CONTINUE_WATCHING_EMPTY')}</div>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const Placeholder = () => {
|
|||
<div className={styles['image-container']}>
|
||||
<Image
|
||||
className={styles['image']}
|
||||
src={require('/images/library_placeholder.png')}
|
||||
src={require('/assets/images/library_placeholder.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ const EpisodePicker = ({ className, onSubmit }: Props) => {
|
|||
|
||||
const { initialSeason, initialEpisode } = useMemo(() => {
|
||||
const splitPath = window.location.hash.split('/');
|
||||
if (splitPath[splitPath.length - 1] === '') {
|
||||
splitPath.pop();
|
||||
}
|
||||
const videoId = decodeURIComponent(splitPath[splitPath.length - 1]);
|
||||
const [, pathSeason, pathEpisode] = videoId ? videoId.split(':') : [];
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,19 @@ const MetaDetails = ({ urlParams, queryParams }) => {
|
|||
}
|
||||
});
|
||||
}, [metaDetails]);
|
||||
const toggleWatched = React.useCallback(() => {
|
||||
if (metaDetails.metaItem === null || metaDetails.metaItem.content.type !== 'Ready') {
|
||||
return;
|
||||
}
|
||||
|
||||
core.transport.dispatch({
|
||||
action: 'MetaDetails',
|
||||
args: {
|
||||
action: 'MarkAsWatched',
|
||||
args: !metaDetails.metaItem.content.content.watched
|
||||
}
|
||||
});
|
||||
}, [metaDetails]);
|
||||
const toggleNotifications = React.useCallback(() => {
|
||||
if (metaDetails.libraryItem) {
|
||||
core.transport.dispatch({
|
||||
|
|
@ -81,7 +94,11 @@ const MetaDetails = ({ urlParams, queryParams }) => {
|
|||
const handleEpisodeSearch = React.useCallback((season, episode) => {
|
||||
const searchVideoHash = encodeURIComponent(`${urlParams.id}:${season}:${episode}`);
|
||||
const url = window.location.hash;
|
||||
const searchVideoPath = url.replace(encodeURIComponent(urlParams.videoId), searchVideoHash);
|
||||
|
||||
const searchVideoPath = (urlParams.videoId === undefined || urlParams.videoId === null || urlParams.videoId === '') ?
|
||||
url + (!url.endsWith('/') ? '/' : '') + searchVideoHash
|
||||
: url.replace(encodeURIComponent(urlParams.videoId), searchVideoHash);
|
||||
|
||||
window.location = searchVideoPath;
|
||||
}, [urlParams, window.location]);
|
||||
|
||||
|
|
@ -130,20 +147,20 @@ const MetaDetails = ({ urlParams, queryParams }) => {
|
|||
metaPath === null ?
|
||||
<DelayedRenderer delay={500}>
|
||||
<div className={styles['meta-message-container']}>
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
|
||||
<div className={styles['message-label']}>{t('ERR_NO_META_SELECTED')}</div>
|
||||
</div>
|
||||
</DelayedRenderer>
|
||||
:
|
||||
metaDetails.metaItem === null ?
|
||||
<div className={styles['meta-message-container']}>
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
|
||||
<div className={styles['message-label']}>{t('ERR_NO_ADDONS_FOR_META')}</div>
|
||||
</div>
|
||||
:
|
||||
metaDetails.metaItem.content.type === 'Err' ?
|
||||
<div className={styles['meta-message-container']}>
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
|
||||
<div className={styles['message-label']}>{t('ERR_NO_META_FOUND')}</div>
|
||||
</div>
|
||||
:
|
||||
|
|
@ -168,6 +185,8 @@ const MetaDetails = ({ urlParams, queryParams }) => {
|
|||
trailerStreams={metaDetails.metaItem.content.content.trailerStreams}
|
||||
inLibrary={metaDetails.metaItem.content.content.inLibrary}
|
||||
toggleInLibrary={metaDetails.metaItem.content.content.inLibrary ? removeFromLibrary : addToLibrary}
|
||||
watched={metaDetails.metaItem.content.content.watched}
|
||||
toggleWatched={toggleWatched}
|
||||
metaId={metaDetails.metaItem.content.content.id}
|
||||
ratingInfo={metaDetails.ratingInfo}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -86,9 +86,17 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
|
|||
}, [href, deepLinks]);
|
||||
|
||||
const streamLink = React.useMemo(() => {
|
||||
return deepLinks?.externalPlayer?.streaming;
|
||||
}, [deepLinks]);
|
||||
|
||||
const downloadLink = React.useMemo(() => {
|
||||
return deepLinks?.externalPlayer?.download;
|
||||
}, [deepLinks]);
|
||||
|
||||
const magnetLink = React.useMemo(() => {
|
||||
return deepLinks?.externalPlayer?.magnet;
|
||||
}, [deepLinks]);
|
||||
|
||||
const markVideoAsWatched = React.useCallback(() => {
|
||||
if (typeof videoId === 'string') {
|
||||
core.transport.dispatch({
|
||||
|
|
@ -102,6 +110,10 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
|
|||
}, [videoId, videoReleased]);
|
||||
|
||||
const onClick = React.useCallback((event) => {
|
||||
if (event.nativeEvent.togglePopupPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (profile.settings.playerType !== null) {
|
||||
markVideoAsWatched();
|
||||
toast.show({
|
||||
|
|
@ -116,6 +128,50 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
|
|||
}
|
||||
}, [props.onClick, profile.settings, markVideoAsWatched]);
|
||||
|
||||
const copyMagnetLink = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
if (magnetLink) {
|
||||
navigator.clipboard.writeText(magnetLink)
|
||||
.then(() => {
|
||||
toast.show({
|
||||
type: 'success',
|
||||
title: t('PLAYER_COPY_MAGNET_LINK_SUCCESS'),
|
||||
timeout: 4000
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.show({
|
||||
type: 'error',
|
||||
title: t('PLAYER_COPY_MAGNET_LINK_ERROR'),
|
||||
timeout: 4000,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [magnetLink]);
|
||||
|
||||
const copyDownloadLink = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
if (downloadLink) {
|
||||
navigator.clipboard.writeText(downloadLink)
|
||||
.then(() => {
|
||||
toast.show({
|
||||
type: 'success',
|
||||
title: t('PLAYER_COPY_DOWNLOAD_LINK_SUCCESS'),
|
||||
timeout: 4000
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.show({
|
||||
type: 'error',
|
||||
title: t('PLAYER_COPY_DOWNLOAD_LINK_ERROR'),
|
||||
timeout: 4000,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [downloadLink]);
|
||||
|
||||
const copyStreamLink = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
|
|
@ -195,6 +251,20 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
|
|||
<div className={styles['context-menu-option-label']}>{t('CTX_COPY_STREAM_LINK')}</div>
|
||||
</Button>
|
||||
}
|
||||
{
|
||||
magnetLink &&
|
||||
<Button className={styles['context-menu-option-container']} title={t('CTX_COPY_MAGNET_LINK')} onClick={copyMagnetLink}>
|
||||
<Icon className={styles['menu-icon']} name={'magnet-link'} />
|
||||
<div className={styles['context-menu-option-label']}>{t('CTX_COPY_MAGNET_LINK')}</div>
|
||||
</Button>
|
||||
}
|
||||
{
|
||||
downloadLink &&
|
||||
<Button className={styles['context-menu-option-container']} title={t('CTX_DOWNLOAD_VIDEO')} onClick={copyDownloadLink}>
|
||||
<Icon className={styles['menu-icon']} name={'download'} />
|
||||
<div className={styles['context-menu-option-label']}>{t('CTX_COPY_VIDEO_DOWNLOAD_LINK')}</div>
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}, [copyStreamLink, onClick]);
|
||||
|
|
@ -234,6 +304,7 @@ Stream.propTypes = {
|
|||
player: PropTypes.string,
|
||||
externalPlayer: PropTypes.shape({
|
||||
download: PropTypes.string,
|
||||
magnet: PropTypes.string,
|
||||
streaming: PropTypes.string,
|
||||
playlist: PropTypes.string,
|
||||
fileName: PropTypes.string,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@
|
|||
width: 7rem;
|
||||
font-size: 1.1rem;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -108,7 +108,9 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
|
|||
<Icon className={styles['icon']} name={'chevron-back'} />
|
||||
</Button>
|
||||
<div className={styles['episode-title']}>
|
||||
{`S${video?.season}E${video?.episode} ${(video?.title)}`}
|
||||
{typeof video.season === 'number' && typeof video.episode === 'number'
|
||||
? `S${video.season}E${video.episode}${video.title ? ` ${video.title}` : ''}`
|
||||
: (video.title ?? '')}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
:
|
||||
|
|
@ -132,7 +134,7 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
|
|||
<SeasonEpisodePicker className={styles['search']} onSubmit={handleEpisodePicker} />
|
||||
: null
|
||||
}
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
|
||||
<div className={styles['label']}>{t('ERR_NO_ADDONS_FOR_STREAMS')}</div>
|
||||
</div>
|
||||
:
|
||||
|
|
@ -148,7 +150,7 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
|
|||
<div className={styles['label']}>{t('UPCOMING')}...</div>
|
||||
: null
|
||||
}
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
|
||||
<div className={styles['label']}>{t('NO_STREAM')}</div>
|
||||
{
|
||||
showInstallAddonsButton ?
|
||||
|
|
@ -168,17 +170,6 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
|
|||
</div>
|
||||
:
|
||||
<React.Fragment>
|
||||
{
|
||||
countLoadingAddons > 0 ?
|
||||
<div className={styles['addons-loading-container']}>
|
||||
<div className={styles['addons-loading']}>
|
||||
{countLoadingAddons} {t('MOBILE_ADDONS_LOADING')}
|
||||
</div>
|
||||
<span className={styles['addons-loading-bar']}></span>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
<div className={styles['streams-container']} ref={streamsContainerRef}>
|
||||
{filteredStreams.map((stream, index) => (
|
||||
<Stream
|
||||
|
|
@ -204,6 +195,17 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
|
|||
null
|
||||
}
|
||||
</div>
|
||||
{
|
||||
countLoadingAddons > 0 ?
|
||||
<div className={styles['addons-loading-container']}>
|
||||
<div className={styles['addons-loading']}>
|
||||
{countLoadingAddons} {t('MOBILE_ADDONS_LOADING')}
|
||||
</div>
|
||||
<span className={styles['addons-loading-bar']}></span>
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</React.Fragment>
|
||||
}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -50,11 +50,12 @@
|
|||
display: flex;
|
||||
z-index: 1;
|
||||
overflow: visible;
|
||||
margin: 2em 1em 0 1em;
|
||||
margin: 2em;
|
||||
gap: 1em;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: var(--border-radius) var(--border-radius) 0 0;
|
||||
|
||||
.addons-loading {
|
||||
color: var(--primary-foreground-color);
|
||||
|
|
@ -198,5 +199,13 @@
|
|||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.addons-loading-container {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
margin: 0;
|
||||
padding: 1em;
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,14 +13,14 @@ const SeasonsBarPlaceholder = ({ className }) => {
|
|||
<div className={classnames(className, styles['seasons-bar-placeholder-container'])}>
|
||||
<div className={styles['prev-season-button']}>
|
||||
<Icon className={styles['icon']} name={'chevron-back'} />
|
||||
<div className={styles['label']}>{t('SEASON_PREV')}</div>
|
||||
<div className={styles['label']}>{t('PREV_SEASON')}</div>
|
||||
</div>
|
||||
<div className={styles['seasons-popup-label-container']}>
|
||||
<div className={styles['seasons-popup-label']}>{t('SEASON_NUMBER', { season: 1 })}</div>
|
||||
<Icon className={styles['seasons-popup-icon']} name={'caret-down'} />
|
||||
</div>
|
||||
<div className={styles['next-season-button']}>
|
||||
<div className={styles['label']}>{t('SEASON_NEXT')}</div>
|
||||
<div className={styles['label']}>{t('NEXT_SEASON')}</div>
|
||||
<Icon className={styles['icon']} name={'chevron-forward'} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
|
|||
metaItem.content.type === 'Err' || videosForSeason.length === 0 ?
|
||||
<div className={styles['message-container']}>
|
||||
<EpisodePicker className={styles['episode-picker']} onSubmit={onSeasonSearch} />
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
|
||||
<div className={styles['label']}>{t('ERR_NO_VIDEOS_FOR_META')}</div>
|
||||
</div>
|
||||
:
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ const useMetaDetails = (urlParams) => {
|
|||
id: urlParams.id,
|
||||
extra: []
|
||||
},
|
||||
streamPath: typeof urlParams.videoId === 'string' ?
|
||||
streamPath: typeof urlParams.videoId === 'string' && urlParams.videoId !== '' ?
|
||||
{
|
||||
resource: 'stream',
|
||||
type: urlParams.type,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@ const useSeason = (urlParams, queryParams) => {
|
|||
const setSeason = React.useCallback((season) => {
|
||||
const nextQueryParams = new URLSearchParams(queryParams);
|
||||
nextQueryParams.set('season', season);
|
||||
window.location.replace(`#${urlParams.path}?${nextQueryParams}`);
|
||||
const path = urlParams.path.endsWith('/') ?
|
||||
urlParams.path.slice(0, -1):
|
||||
urlParams.path;
|
||||
|
||||
window.location.replace(`#${path}?${nextQueryParams}`);
|
||||
}, [urlParams, queryParams]);
|
||||
return [season, setSeason];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const NotFound = () => {
|
|||
<div className={styles['not-found-content']}>
|
||||
<Image
|
||||
className={styles['not-found-image']}
|
||||
src={require('/images/empty.png')}
|
||||
src={require('/assets/images/empty.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
<div className={styles['not-found-label']}>{t('PAGE_NOT_FOUND')}</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 25rem;
|
||||
width: 16rem;
|
||||
|
||||
.header {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { MouseEvent, useCallback } from 'react';
|
||||
import React, { forwardRef, memo, MouseEvent, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classNames from 'classnames';
|
||||
import { languages } from 'stremio/common';
|
||||
|
|
@ -12,7 +12,7 @@ type Props = {
|
|||
onAudioTrackSelected: (id: string) => void,
|
||||
};
|
||||
|
||||
const AudioMenu = ({ className, selectedAudioTrackId, audioTracks, onAudioTrackSelected }: Props) => {
|
||||
const AudioMenu = memo(forwardRef<HTMLDivElement, Props>(({ className, selectedAudioTrackId, audioTracks, onAudioTrackSelected }: Props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onAudioTrackClick = useCallback(({ currentTarget }: MouseEvent) => {
|
||||
|
|
@ -26,7 +26,7 @@ const AudioMenu = ({ className, selectedAudioTrackId, audioTracks, onAudioTrackS
|
|||
};
|
||||
|
||||
return (
|
||||
<div className={classNames(className, styles['audio-menu'])} onMouseDown={onMouseDown}>
|
||||
<div ref={ref} className={classNames(className, styles['audio-menu'])} onMouseDown={onMouseDown}>
|
||||
<div className={styles['container']}>
|
||||
<div className={styles['header']}>
|
||||
{ t('AUDIO_TRACKS') }
|
||||
|
|
@ -62,6 +62,6 @@ const AudioMenu = ({ className, selectedAudioTrackId, audioTracks, onAudioTrackS
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}));
|
||||
|
||||
export default AudioMenu;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const BufferingLoader = React.forwardRef(({ className, logo }, ref) => {
|
|||
className={styles['buffering-loader']}
|
||||
src={logo}
|
||||
alt={' '}
|
||||
fallbackSrc={require('/images/stremio_symbol.png')}
|
||||
fallbackSrc={require('/assets/images/stremio_symbol.png')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ const styles = require('./styles');
|
|||
const { useBinaryState, usePlatform } = require('stremio/common');
|
||||
const { t } = require('i18next');
|
||||
|
||||
const ControlBar = ({
|
||||
const ControlBar = React.forwardRef(({
|
||||
className,
|
||||
paused,
|
||||
time,
|
||||
|
|
@ -42,7 +42,7 @@ const ControlBar = ({
|
|||
onToggleStatisticsMenu,
|
||||
onTouchEnd,
|
||||
...props
|
||||
}) => {
|
||||
}, ref) => {
|
||||
const { chromecast } = useServices();
|
||||
const platform = usePlatform();
|
||||
const [chromecastServiceActive, setChromecastServiceActive] = React.useState(() => chromecast.active);
|
||||
|
|
@ -105,7 +105,7 @@ const ControlBar = ({
|
|||
};
|
||||
}, []);
|
||||
return (
|
||||
<div {...props} onTouchStart={props.onMouseOver} onTouchMove={props.onMouseMove} onTouchEnd={onTouchEnd} className={classnames(className, styles['control-bar-container'])}>
|
||||
<div ref={ref} {...props} onTouchStart={props.onMouseOver} onTouchMove={props.onMouseMove} onTouchEnd={onTouchEnd} className={classnames(className, styles['control-bar-container'])}>
|
||||
<SeekBar
|
||||
className={styles['seek-bar']}
|
||||
time={time}
|
||||
|
|
@ -183,7 +183,7 @@ const ControlBar = ({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
ControlBar.propTypes = {
|
||||
className: PropTypes.string,
|
||||
|
|
|
|||