mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-04-21 03:22:11 +00:00
Merge branch 'development' into feat/stream-converted-source
This commit is contained in:
commit
4fba2a3770
71 changed files with 11978 additions and 15868 deletions
4
.github/workflows/auto_assign.yml
vendored
4
.github/workflows/auto_assign.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
# Auto assign PR to author
|
# Auto assign PR to author
|
||||||
- name: Auto Assign PR to Author
|
- name: Auto Assign PR to Author
|
||||||
if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
|
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@v8
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
@ -31,7 +31,7 @@ jobs:
|
||||||
|
|
||||||
# Dynamic labeling based on PR/Issue title
|
# Dynamic labeling based on PR/Issue title
|
||||||
- name: Label PRs and Issues
|
- name: Label PRs and Issues
|
||||||
if: github.actor != 'dependabot[bot]'
|
if: github.event.pull_request.head.repo.fork == false && github.actor != 'dependabot[bot]'
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
uses: actions/github-script@v8
|
uses: actions/github-script@v8
|
||||||
|
|
|
||||||
22
.github/workflows/build.yml
vendored
22
.github/workflows/build.yml
vendored
|
|
@ -5,7 +5,7 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- development
|
- development
|
||||||
tags-ignore:
|
tags-ignore:
|
||||||
- '**'
|
- "**"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- development
|
- development
|
||||||
|
|
@ -20,20 +20,26 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
run_install: false
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
|
cache: "pnpm"
|
||||||
- name: Install NPM dependencies
|
- name: Install NPM dependencies
|
||||||
run: npm ci
|
run: pnpm install
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: pnpm build
|
||||||
- name: Test
|
- name: Test
|
||||||
run: npm test
|
run: pnpm test
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: pnpm lint
|
||||||
# Create recursivelly the destiantion dir with
|
# Create recursively the destination dir with
|
||||||
# "--parrents where no error if existing, make parent directories as needed."
|
# "--parrents where no error if existing, make parent directories as needed."
|
||||||
- run: mkdir -p ./build/${{ github.head_ref || github.ref_name }}
|
- run: mkdir -p ./build/${{ github.head_ref || github.ref_name }}
|
||||||
- name: Deploy to GitHub Pages
|
- name: Deploy to GitHub Pages
|
||||||
|
|
|
||||||
33
.github/workflows/pages_cleanup.yml
vendored
33
.github/workflows/pages_cleanup.yml
vendored
|
|
@ -9,22 +9,39 @@ permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
cleanup:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: gh-pages
|
ref: gh-pages
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Delete directories older than 1 year
|
- name: Delete directories that don't have existing branch
|
||||||
run: |
|
run: |
|
||||||
for dir in $(find . -mindepth 1 -maxdepth 2 -type d -not -path '*/\.*'); do
|
branches=( $(git branch -r | grep origin | grep -v HEAD | sed 's|origin/||') )
|
||||||
if ! git log -1 --since="1 year ago" -- "$dir" | grep -q .; then
|
declare -p branches
|
||||||
echo "Deleting $dir"
|
|
||||||
rm -rf "$dir"
|
find . -mindepth 1 -maxdepth 2 -type d -not -path '*/\.*' | while read -r dir; do
|
||||||
fi
|
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
|
done
|
||||||
|
|
||||||
- name: Commit and push
|
- name: Commit and push
|
||||||
|
|
|
||||||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
|
|
@ -9,20 +9,20 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
- name: Install NPM dependencies
|
- name: Install NPM dependencies
|
||||||
run: npm install
|
run: pnpm install
|
||||||
- name: Build
|
- name: Build
|
||||||
env:
|
env:
|
||||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||||
run: npm run build
|
run: pnpm build
|
||||||
- name: Zip build artifact
|
- name: Zip build artifact
|
||||||
run: zip -r stremio-web.zip ./build
|
run: zip -r stremio-web.zip ./build
|
||||||
- name: Upload build artifact to GitHub release assets
|
- name: Upload build artifact to GitHub release assets
|
||||||
uses: svenstaro/upload-release-action@2.11.2
|
uses: svenstaro/upload-release-action@2.11.3
|
||||||
with:
|
with:
|
||||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
file: stremio-web.zip
|
file: stremio-web.zip
|
||||||
asset_name: stremio-web.zip
|
asset_name: stremio-web.zip
|
||||||
tag: ${{ github.ref }}
|
tag: ${{ github.ref }}
|
||||||
overwrite: true
|
overwrite: true
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,10 @@ Project maintainers are responsible for enforcing this code of conduct. They can
|
||||||
## Suggestions for newbies
|
## 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.
|
- 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 excesive comments generated by AI.
|
- Refrain from excessive comments generated by AI.
|
||||||
- Refrain from docs generated entirely 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.
|
- 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 how to tackle a problem and AI can't help you, please just ask or look in Stack Overlflow, Google, Medium etc.
|
- 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?!
|
- 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
|
## Scope
|
||||||
|
|
|
||||||
38
Dockerfile
38
Dockerfile
|
|
@ -3,29 +3,39 @@
|
||||||
ARG NODE_VERSION=20-alpine
|
ARG NODE_VERSION=20-alpine
|
||||||
FROM node:$NODE_VERSION AS base
|
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
|
# Meta
|
||||||
LABEL Description="Stremio Web" Vendor="Smart Code OOD" Version="1.0.0"
|
LABEL Description="Stremio Web" Vendor="Smart Code OOD" Version="1.0.0"
|
||||||
|
|
||||||
RUN mkdir -p /var/www/stremio-web
|
RUN mkdir -p /var/www/stremio-web
|
||||||
WORKDIR /var/www/stremio-web
|
WORKDIR /var/www/stremio-web
|
||||||
|
|
||||||
# Install app dependencies
|
# Setup app
|
||||||
FROM base AS prebuild
|
FROM base AS app
|
||||||
|
|
||||||
RUN apk update && apk upgrade && \
|
COPY package.json pnpm-lock.yaml /var/www/stremio-web
|
||||||
apk add --no-cache git
|
RUN pnpm i --frozen-lockfile
|
||||||
WORKDIR /var/www/stremio-web
|
|
||||||
COPY . .
|
|
||||||
RUN npm install
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Bundle app source
|
COPY . /var/www/stremio-web
|
||||||
FROM base AS final
|
RUN pnpm build
|
||||||
|
|
||||||
WORKDIR /var/www/stremio-web
|
# Setup server
|
||||||
COPY . .
|
FROM base AS server
|
||||||
COPY --from=prebuild /var/www/stremio-web/node_modules ./node_modules
|
|
||||||
COPY --from=prebuild /var/www/stremio-web/build ./build
|
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
|
EXPOSE 8080
|
||||||
CMD ["node", "http_server.js"]
|
CMD ["node", "http_server.js"]
|
||||||
|
|
|
||||||
17
README.md
17
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Stremio - Freedom to Stream
|
# Stremio - Freedom to Stream
|
||||||
|
|
||||||

|
[](https://github.com/Stremio/stremio-web/actions/workflows/build.yml)
|
||||||
[](https://stremio.github.io/stremio-web/development)
|
[](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.
|
Stremio is a modern media center that's a one-stop solution for your video entertainment. You discover, watch and organize video content from easy to install addons.
|
||||||
|
|
@ -10,24 +10,31 @@ Stremio is a modern media center that's a one-stop solution for your video enter
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
* Node.js 12 or higher
|
* Node.js 12 or higher
|
||||||
* npm 6 or higher
|
* [pnpm](https://pnpm.io/installation) 10 or higher
|
||||||
|
|
||||||
### Install dependencies
|
### Install dependencies
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Start development server
|
### Start development server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start
|
pnpm start
|
||||||
```
|
```
|
||||||
|
|
||||||
### Production build
|
### Production build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
pnpm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run with Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t stremio-web .
|
||||||
|
docker run -p 8080:8080 stremio-web
|
||||||
```
|
```
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ export default [
|
||||||
'@stylistic/semi-spacing': 'error',
|
'@stylistic/semi-spacing': 'error',
|
||||||
'@stylistic/space-before-blocks': 'error',
|
'@stylistic/space-before-blocks': 'error',
|
||||||
'@stylistic/no-trailing-spaces': 'error',
|
'@stylistic/no-trailing-spaces': 'error',
|
||||||
'@stylistic/func-call-spacing': 'error',
|
'@stylistic/function-call-spacing': 'error',
|
||||||
'@stylistic/semi': 'error',
|
'@stylistic/semi': 'error',
|
||||||
'@stylistic/no-extra-semi': 'error',
|
'@stylistic/no-extra-semi': 'error',
|
||||||
'@stylistic/eol-last': 'error',
|
'@stylistic/eol-last': 'error',
|
||||||
|
|
|
||||||
15563
package-lock.json
generated
15563
package-lock.json
generated
File diff suppressed because it is too large
Load diff
14
package.json
14
package.json
|
|
@ -11,15 +11,15 @@
|
||||||
"build": "webpack --mode production",
|
"build": "webpack --mode production",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"lint": "eslint src",
|
"lint": "eslint src",
|
||||||
"scan-translations": "npx jest ./tests/i18nScan.test.js"
|
"scan-translations": "pnpx jest ./tests/i18nScan.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "7.26.0",
|
"@babel/runtime": "7.26.0",
|
||||||
"@sentry/browser": "8.42.0",
|
"@sentry/browser": "8.42.0",
|
||||||
"@stremio/stremio-colors": "5.2.0",
|
"@stremio/stremio-colors": "5.2.0",
|
||||||
"@stremio/stremio-core-web": "https://stremio.github.io/stremio-core/stremio-core-web/feat/stream-rar-and-zip-stream-creation/dev/stremio-stremio-core-web-0.49.4.tgz",
|
"@stremio/stremio-core-web": "0.50.0",
|
||||||
"@stremio/stremio-icons": "5.7.1",
|
"@stremio/stremio-icons": "5.8.0",
|
||||||
"@stremio/stremio-video": "0.0.62",
|
"@stremio/stremio-video": "0.0.64",
|
||||||
"a-color-picker": "1.2.1",
|
"a-color-picker": "1.2.1",
|
||||||
"bowser": "2.11.0",
|
"bowser": "2.11.0",
|
||||||
"buffer": "6.0.3",
|
"buffer": "6.0.3",
|
||||||
|
|
@ -41,7 +41,7 @@
|
||||||
"react-i18next": "^15.1.3",
|
"react-i18next": "^15.1.3",
|
||||||
"react-is": "18.3.1",
|
"react-is": "18.3.1",
|
||||||
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
|
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
|
||||||
"stremio-translations": "github:Stremio/stremio-translations#abe7684165a031755e9aee39da26daa806ba7824",
|
"stremio-translations": "github:Stremio/stremio-translations#01aaa201e419782b26b9f2cbe4430795021426e5",
|
||||||
"url": "0.11.4",
|
"url": "0.11.4",
|
||||||
"use-long-press": "^3.2.0"
|
"use-long-press": "^3.2.0"
|
||||||
},
|
},
|
||||||
|
|
@ -50,8 +50,8 @@
|
||||||
"@babel/preset-env": "7.26.0",
|
"@babel/preset-env": "7.26.0",
|
||||||
"@babel/preset-react": "7.26.3",
|
"@babel/preset-react": "7.26.3",
|
||||||
"@eslint/js": "^9.16.0",
|
"@eslint/js": "^9.16.0",
|
||||||
"@stylistic/eslint-plugin": "^2.11.0",
|
"@stylistic/eslint-plugin": "^5.4.0",
|
||||||
"@stylistic/eslint-plugin-jsx": "^2.11.0",
|
"@stylistic/eslint-plugin-jsx": "^4.4.1",
|
||||||
"@types/hat": "^0.0.4",
|
"@types/hat": "^0.0.4",
|
||||||
"@types/lodash.isequal": "^4.5.8",
|
"@types/lodash.isequal": "^4.5.8",
|
||||||
"@types/lodash.throttle": "^4.1.9",
|
"@types/lodash.throttle": "^4.1.9",
|
||||||
|
|
|
||||||
11030
pnpm-lock.yaml
Normal file
11030
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
Binary file not shown.
|
Before Width: | Height: | Size: 1 MiB After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 947 KiB After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.6 MiB |
|
|
@ -6,11 +6,12 @@ const { useTranslation } = require('react-i18next');
|
||||||
const { Router } = require('stremio-router');
|
const { Router } = require('stremio-router');
|
||||||
const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services');
|
const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services');
|
||||||
const { NotFound } = require('stremio/routes');
|
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 ServicesToaster = require('./ServicesToaster');
|
||||||
const DeepLinkHandler = require('./DeepLinkHandler');
|
const DeepLinkHandler = require('./DeepLinkHandler');
|
||||||
const SearchParamsHandler = require('./SearchParamsHandler');
|
const SearchParamsHandler = require('./SearchParamsHandler');
|
||||||
const { default: UpdaterBanner } = require('./UpdaterBanner');
|
const { default: UpdaterBanner } = require('./UpdaterBanner');
|
||||||
|
const { default: ShortcutsModal } = require('./ShortcutsModal');
|
||||||
const ErrorDialog = require('./ErrorDialog');
|
const ErrorDialog = require('./ErrorDialog');
|
||||||
const withProtectedRoutes = require('./withProtectedRoutes');
|
const withProtectedRoutes = require('./withProtectedRoutes');
|
||||||
const routerViewsConfig = require('./routerViewsConfig');
|
const routerViewsConfig = require('./routerViewsConfig');
|
||||||
|
|
@ -38,6 +39,14 @@ const App = () => {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
const [initialized, setInitialized] = React.useState(false);
|
const [initialized, setInitialized] = React.useState(false);
|
||||||
|
const [shortcutModalOpen,, closeShortcutsModal, toggleShortcutModal] = useBinaryState(false);
|
||||||
|
|
||||||
|
const onShortcut = React.useCallback((name) => {
|
||||||
|
if (name === 'shortcuts') {
|
||||||
|
toggleShortcutModal();
|
||||||
|
}
|
||||||
|
}, [toggleShortcutModal]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let prevPath = window.location.hash.slice(1);
|
let prevPath = window.location.hash.slice(1);
|
||||||
const onLocationHashChange = () => {
|
const onLocationHashChange = () => {
|
||||||
|
|
@ -204,15 +213,20 @@ const App = () => {
|
||||||
<ToastProvider className={styles['toasts-container']}>
|
<ToastProvider className={styles['toasts-container']}>
|
||||||
<TooltipProvider className={styles['tooltip-container']}>
|
<TooltipProvider className={styles['tooltip-container']}>
|
||||||
<FileDropProvider className={styles['file-drop-container']}>
|
<FileDropProvider className={styles['file-drop-container']}>
|
||||||
<ServicesToaster />
|
<ShortcutsProvider onShortcut={onShortcut}>
|
||||||
<DeepLinkHandler />
|
{
|
||||||
<SearchParamsHandler />
|
shortcutModalOpen && <ShortcutsModal onClose={closeShortcutsModal}/>
|
||||||
<UpdaterBanner className={styles['updater-banner-container']} />
|
}
|
||||||
<RouterWithProtectedRoutes
|
<ServicesToaster />
|
||||||
className={styles['router']}
|
<DeepLinkHandler />
|
||||||
viewsConfig={routerViewsConfig}
|
<SearchParamsHandler />
|
||||||
onPathNotMatch={onPathNotMatch}
|
<UpdaterBanner className={styles['updater-banner-container']} />
|
||||||
/>
|
<RouterWithProtectedRoutes
|
||||||
|
className={styles['router']}
|
||||||
|
viewsConfig={routerViewsConfig}
|
||||||
|
onPathNotMatch={onPathNotMatch}
|
||||||
|
/>
|
||||||
|
</ShortcutsProvider>
|
||||||
</FileDropProvider>
|
</FileDropProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,13 @@ const SearchParamsHandler = () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
core.transport.dispatch({
|
||||||
|
action: 'Ctx',
|
||||||
|
args: {
|
||||||
|
action: 'AddServerUrl',
|
||||||
|
args: streamingServerUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
toast.show({
|
toast.show({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: `Using streaming server at ${streamingServerUrl}`,
|
title: `Using streaming server at ${streamingServerUrl}`,
|
||||||
|
|
|
||||||
59
src/App/ShortcutsModal/ShortcutsModal.tsx
Normal file
59
src/App/ShortcutsModal/ShortcutsModal.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
// Copyright (C) 2017-2023 Smart code 203358507
|
||||||
|
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import Icon from '@stremio/stremio-icons/react';
|
||||||
|
import { useShortcuts } from 'stremio/common';
|
||||||
|
import { Button, ShortcutsGroup } from 'stremio/components';
|
||||||
|
import styles from './styles.less';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onClose: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ShortcutsModal = ({ onClose }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { grouped } = useShortcuts();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = ({ key }: KeyboardEvent) => {
|
||||||
|
key === 'Escape' && onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return createPortal((
|
||||||
|
<div className={styles['shortcuts-modal']}>
|
||||||
|
<div className={styles['backdrop']} onClick={onClose} />
|
||||||
|
|
||||||
|
<div className={styles['container']}>
|
||||||
|
<div className={styles['header']}>
|
||||||
|
<div className={styles['title']}>
|
||||||
|
{t('SETTINGS_NAV_SHORTCUTS')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button className={styles['close-button']} title={t('BUTTON_CLOSE')} onClick={onClose}>
|
||||||
|
<Icon className={styles['icon']} name={'close'} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles['content']}>
|
||||||
|
{
|
||||||
|
grouped.map(({ name, label, shortcuts }) => (
|
||||||
|
<ShortcutsGroup
|
||||||
|
key={name}
|
||||||
|
label={label}
|
||||||
|
shortcuts={shortcuts}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
), document.body);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortcutsModal;
|
||||||
2
src/App/ShortcutsModal/index.ts
Normal file
2
src/App/ShortcutsModal/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
import ShortcutsModal from './ShortcutsModal';
|
||||||
|
export default ShortcutsModal;
|
||||||
91
src/App/ShortcutsModal/styles.less
Normal file
91
src/App/ShortcutsModal/styles.less
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
|
||||||
|
|
||||||
|
.shortcuts-modal {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.backdrop {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: @color-background-dark5-40;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
max-height: 80%;
|
||||||
|
max-width: 80%;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
background-color: var(--modal-background-color);
|
||||||
|
box-shadow: var(--outer-glow);
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: 5rem;
|
||||||
|
padding-left: 2.5rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
position: relative;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--primary-foreground-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
position: relative;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
z-index: 2;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--primary-foreground-color);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover, &:focus {
|
||||||
|
.icon {
|
||||||
|
opacity: 1;
|
||||||
|
color: var(--primary-foreground-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline-color: var(--primary-foreground-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 3rem;
|
||||||
|
padding: 0 2.5rem;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -35,7 +35,7 @@
|
||||||
@top-overlay-size: 5.25rem;
|
@top-overlay-size: 5.25rem;
|
||||||
@bottom-overlay-size: 0rem;
|
@bottom-overlay-size: 0rem;
|
||||||
@overlap-size: 3rem;
|
@overlap-size: 3rem;
|
||||||
@transparency-grandient-pad: 6rem;
|
@transparency-gradient-pad: 6rem;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--landscape-shape-ratio: 0.5625;
|
--landscape-shape-ratio: 0.5625;
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
--top-overlay-size: @top-overlay-size;
|
--top-overlay-size: @top-overlay-size;
|
||||||
--bottom-overlay-size: @bottom-overlay-size;
|
--bottom-overlay-size: @bottom-overlay-size;
|
||||||
--overlap-size: @overlap-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-top: @safe-area-inset-top;
|
||||||
--safe-area-inset-right: @safe-area-inset-right;
|
--safe-area-inset-right: @safe-area-inset-right;
|
||||||
--safe-area-inset-bottom: @safe-area-inset-bottom;
|
--safe-area-inset-bottom: @safe-area-inset-bottom;
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ const FileDropProvider = ({ className, children }: Props) => {
|
||||||
.then((buffer) => {
|
.then((buffer) => {
|
||||||
listeners
|
listeners
|
||||||
.filter(([type]) => file.type ? type === file.type : isFileType(buffer, type))
|
.filter(([type]) => file.type ? type === file.type : isFileType(buffer, type))
|
||||||
.forEach(([, listerner]) => listerner(file.name, buffer));
|
.forEach(([, listener]) => listener(file.name, buffer));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
54
src/common/Shortcuts/Shortcuts.tsx
Normal file
54
src/common/Shortcuts/Shortcuts.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import React, { createContext, useCallback, useContext, useEffect } from 'react';
|
||||||
|
import shortcuts from './shortcuts.json';
|
||||||
|
|
||||||
|
const SHORTCUTS = shortcuts.map(({ shortcuts }) => shortcuts).flat();
|
||||||
|
|
||||||
|
export type ShortcutName = string;
|
||||||
|
export type ShortcutListener = () => void;
|
||||||
|
|
||||||
|
interface ShortcutsContext {
|
||||||
|
grouped: ShortcutGroup[],
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShortcutsContext = createContext<ShortcutsContext>({} as ShortcutsContext);
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: JSX.Element,
|
||||||
|
onShortcut: (name: ShortcutName) => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ShortcutsProvider = ({ children, onShortcut }: Props) => {
|
||||||
|
const onKeyDown = useCallback(({ ctrlKey, shiftKey, 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())) {
|
||||||
|
onShortcut(name as ShortcutName);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}, [onShortcut]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
};
|
||||||
|
}, [onKeyDown]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ShortcutsContext.Provider value={{ grouped: shortcuts }}>
|
||||||
|
{children}
|
||||||
|
</ShortcutsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useShortcuts = () => {
|
||||||
|
return useContext(ShortcutsContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
ShortcutsProvider,
|
||||||
|
useShortcuts
|
||||||
|
};
|
||||||
5
src/common/Shortcuts/index.ts
Normal file
5
src/common/Shortcuts/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { ShortcutsProvider, useShortcuts } from './Shortcuts';
|
||||||
|
export {
|
||||||
|
ShortcutsProvider,
|
||||||
|
useShortcuts,
|
||||||
|
};
|
||||||
89
src/common/Shortcuts/shortcuts.json
Normal file
89
src/common/Shortcuts/shortcuts.json
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"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": "subtitlesSize",
|
||||||
|
"label": "SETTINGS_SHORTCUT_SUBTITLES_SIZE",
|
||||||
|
"combos": [["-"], ["="]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "subtitlesDelay",
|
||||||
|
"label": "SETTINGS_SHORTCUT_SUBTITLES_DELAY",
|
||||||
|
"combos": [["G"], ["H"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "subtitlesMenu",
|
||||||
|
"label": "SETTINGS_SHORTCUT_MENU_SUBTITLES",
|
||||||
|
"combos": [["S"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "audioMenu",
|
||||||
|
"label": "SETTINGS_SHORTCUT_MENU_AUDIO",
|
||||||
|
"combos": [["A"]]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "infoMenu",
|
||||||
|
"label": "SETTINGS_SHORTCUT_MENU_INFO",
|
||||||
|
"combos": [["I"]]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
11
src/common/Shortcuts/types.d.ts
vendored
Normal file
11
src/common/Shortcuts/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
type Shortcut = {
|
||||||
|
name: string,
|
||||||
|
label: string,
|
||||||
|
combos: string[][],
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShortcutGroup = {
|
||||||
|
name: string,
|
||||||
|
label: string,
|
||||||
|
shortcuts: Shortcut[],
|
||||||
|
};
|
||||||
|
|
@ -4,6 +4,7 @@ const { FileDropProvider, onFileDrop } = require('./FileDrop');
|
||||||
const { PlatformProvider, usePlatform } = require('./Platform');
|
const { PlatformProvider, usePlatform } = require('./Platform');
|
||||||
const { ToastProvider, useToast } = require('./Toast');
|
const { ToastProvider, useToast } = require('./Toast');
|
||||||
const { TooltipProvider, Tooltip } = require('./Tooltips');
|
const { TooltipProvider, Tooltip } = require('./Tooltips');
|
||||||
|
const { ShortcutsProvider, useShortcuts } = require('./Shortcuts');
|
||||||
const comparatorWithPriorities = require('./comparatorWithPriorities');
|
const comparatorWithPriorities = require('./comparatorWithPriorities');
|
||||||
const CONSTANTS = require('./CONSTANTS');
|
const CONSTANTS = require('./CONSTANTS');
|
||||||
const { withCoreSuspender, useCoreSuspender } = require('./CoreSuspender');
|
const { withCoreSuspender, useCoreSuspender } = require('./CoreSuspender');
|
||||||
|
|
@ -35,6 +36,8 @@ module.exports = {
|
||||||
onFileDrop,
|
onFileDrop,
|
||||||
PlatformProvider,
|
PlatformProvider,
|
||||||
usePlatform,
|
usePlatform,
|
||||||
|
ShortcutsProvider,
|
||||||
|
useShortcuts,
|
||||||
ToastProvider,
|
ToastProvider,
|
||||||
useToast,
|
useToast,
|
||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,10 @@
|
||||||
"name": "فارسی",
|
"name": "فارسی",
|
||||||
"codes": ["fa-IR", "fas"]
|
"codes": ["fa-IR", "fas"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Suomi",
|
||||||
|
"codes": ["fi-FI", "fin"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Français",
|
"name": "Français",
|
||||||
"codes": ["fr-FR", "fre"]
|
"codes": ["fr-FR", "fre"]
|
||||||
|
|
@ -119,13 +123,17 @@
|
||||||
"name": "português",
|
"name": "português",
|
||||||
"codes": ["pt-PT", "por"]
|
"codes": ["pt-PT", "por"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Română",
|
||||||
|
"codes": ["ro-RO", "ron"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "русский язык",
|
"name": "русский язык",
|
||||||
"codes": ["ru-RU", "rus"]
|
"codes": ["ru-RU", "rus"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Svenska",
|
"name": "Slovenčina",
|
||||||
"codes": ["sv-SE", "swe"]
|
"codes": ["sk-SK", "slk"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "slovenski jezik",
|
"name": "slovenski jezik",
|
||||||
|
|
@ -135,6 +143,10 @@
|
||||||
"name": "српски језик",
|
"name": "српски језик",
|
||||||
"codes": ["sr-RS", "srp"]
|
"codes": ["sr-RS", "srp"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Svenska",
|
||||||
|
"codes": ["sv-SE", "swe"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "తెలుగు",
|
"name": "తెలుగు",
|
||||||
"codes": ["te-IN", "tel"]
|
"codes": ["te-IN", "tel"]
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,15 @@ const useFullscreen = () => {
|
||||||
|
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
|
||||||
const requestFullscreen = useCallback(() => {
|
const requestFullscreen = useCallback(async () => {
|
||||||
if (shell.active) {
|
if (shell.active) {
|
||||||
shell.send('win-set-visibility', { fullscreen: true });
|
shell.send('win-set-visibility', { fullscreen: true });
|
||||||
} else {
|
} else {
|
||||||
document.documentElement.requestFullscreen();
|
try {
|
||||||
|
await document.documentElement.requestFullscreen();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error enabling fullscreen', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
|
||||||
4
src/common/useNotifications.d.ts
vendored
4
src/common/useNotifications.d.ts
vendored
|
|
@ -1,2 +1,2 @@
|
||||||
declare const useNotifcations: () => Notifications;
|
declare const useNotifications: () => Notifications;
|
||||||
export = useNotifcations;
|
export = useNotifications;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
.bottom-sheet {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const ColorPicker = ({ className, value, onInput }) => {
|
||||||
showRGB: false,
|
showRGB: false,
|
||||||
showAlpha: true
|
showAlpha: true
|
||||||
});
|
});
|
||||||
const pickerClipboard = pickerElementRef.current.querySelector('.a-color-picker-clipbaord');
|
const pickerClipboard = pickerElementRef.current.querySelector('.a-color-picker-clipboard');
|
||||||
if (pickerClipboard instanceof HTMLElement) {
|
if (pickerClipboard instanceof HTMLElement) {
|
||||||
pickerClipboard.tabIndex = -1;
|
pickerClipboard.tabIndex = -1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
box-shadow: 0 0 .2rem var(--color-surfacedark);
|
box-shadow: 0 0 .2rem var(--color-surfacedark);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.a-color-picker-clipbaord) {
|
:global(.a-color-picker-clipboard) {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Copyright (C) 2017-2023 Smart code 203358507
|
// Copyright (C) 2017-2023 Smart code 203358507
|
||||||
|
|
||||||
const ContineWatchingItem = require('./ContinueWatchingItem');
|
const ContinueWatchingItem = require('./ContinueWatchingItem');
|
||||||
|
|
||||||
module.exports = ContineWatchingItem;
|
module.exports = ContinueWatchingItem;
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,15 @@
|
||||||
|
|
||||||
.icon-container {
|
.icon-container {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-container {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3,18 +3,18 @@
|
||||||
const React = require('react');
|
const React = require('react');
|
||||||
const PropTypes = require('prop-types');
|
const PropTypes = require('prop-types');
|
||||||
const classnames = require('classnames');
|
const classnames = require('classnames');
|
||||||
const { useTranslation } = require('react-i18next');
|
|
||||||
const { Button } = require('stremio/components');
|
const { Button } = require('stremio/components');
|
||||||
|
const useTranslate = require('stremio/common/useTranslate');
|
||||||
const styles = require('./styles');
|
const styles = require('./styles');
|
||||||
|
|
||||||
const MetaLinks = ({ className, label, links }) => {
|
const MetaLinks = ({ className, label, links }) => {
|
||||||
const { t } = useTranslation();
|
const { string, stringWithPrefix } = useTranslate();
|
||||||
return (
|
return (
|
||||||
<div className={classnames(className, styles['meta-links-container'])}>
|
<div className={classnames(className, styles['meta-links-container'])}>
|
||||||
{
|
{
|
||||||
typeof label === 'string' && label.length > 0 ?
|
typeof label === 'string' && label.length > 0 ?
|
||||||
<div className={styles['label-container']}>
|
<div className={styles['label-container']}>
|
||||||
{t(`LINKS_${label.toUpperCase()}`)}
|
{ stringWithPrefix(label.toUpperCase(), 'LINKS') }
|
||||||
</div>
|
</div>
|
||||||
:
|
:
|
||||||
null
|
null
|
||||||
|
|
@ -24,7 +24,7 @@ const MetaLinks = ({ className, label, links }) => {
|
||||||
<div className={styles['links-container']}>
|
<div className={styles['links-container']}>
|
||||||
{links.map(({ label, href }, index) => (
|
{links.map(({ label, href }, index) => (
|
||||||
<Button key={index} className={styles['link-container']} title={label} href={href}>
|
<Button key={index} className={styles['link-container']} title={label} href={href}>
|
||||||
{ t(label) }
|
{ string(label) }
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
22
src/components/ShortcutsGroup/Combos/Combos.less
Normal file
22
src/components/ShortcutsGroup/Combos/Combos.less
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
.combos {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
.combo {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 3.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--primary-foreground-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/components/ShortcutsGroup/Combos/Combos.tsx
Normal file
33
src/components/ShortcutsGroup/Combos/Combos.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import Keys from './Keys';
|
||||||
|
import styles from './Combos.less';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
combos: string[][],
|
||||||
|
};
|
||||||
|
|
||||||
|
const Combos = ({ combos }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles['combos']}>
|
||||||
|
{
|
||||||
|
combos.map((keys, index) => (
|
||||||
|
<div className={styles['combo']} key={index}>
|
||||||
|
<Keys keys={keys} />
|
||||||
|
{
|
||||||
|
index < (combos.length - 1) && (
|
||||||
|
<div className={styles['separator']}>
|
||||||
|
{ t('SETTINGS_SHORTCUT_OR') }
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Combos;
|
||||||
26
src/components/ShortcutsGroup/Combos/Keys/Keys.less
Normal file
26
src/components/ShortcutsGroup/Combos/Keys/Keys.less
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
kbd {
|
||||||
|
flex: none;
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 2.5rem;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--primary-foreground-color);
|
||||||
|
border-radius: 0.25em;
|
||||||
|
box-shadow: 0 4px 0 1px rgba(255, 255, 255, 0.1);
|
||||||
|
background-color: var(--overlay-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--primary-foreground-color);
|
||||||
|
}
|
||||||
51
src/components/ShortcutsGroup/Combos/Keys/Keys.tsx
Normal file
51
src/components/ShortcutsGroup/Combos/Keys/Keys.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import React, { Fragment, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import styles from './Keys.less';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
keys: string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
const Keys = ({ keys }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const keyLabelMap: Record<string, string> = useMemo(() => ({
|
||||||
|
'Shift': `⇧ ${t('SETTINGS_SHORTCUT_SHIFT')}`,
|
||||||
|
'Space': t('SETTINGS_SHORTCUT_SPACE'),
|
||||||
|
'Ctrl': t('SETTINGS_SHORTCUT_CTRL'),
|
||||||
|
'Escape': t('SETTINGS_SHORTCUT_ESC'),
|
||||||
|
'ArrowUp': '↑',
|
||||||
|
'ArrowDown': '↓',
|
||||||
|
'ArrowLeft': '←',
|
||||||
|
'ArrowRight': '→',
|
||||||
|
}), [t]);
|
||||||
|
|
||||||
|
const isRange = useMemo(() => {
|
||||||
|
return keys.length > 1 && keys.every((key) => !Number.isNaN(parseInt(key)));
|
||||||
|
}, [keys]);
|
||||||
|
|
||||||
|
const filteredKeys = useMemo(() => {
|
||||||
|
return isRange ? [keys[0], keys[keys.length - 1]] : keys;
|
||||||
|
}, [keys, isRange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
filteredKeys.map((key, index) => (
|
||||||
|
<Fragment key={key}>
|
||||||
|
<kbd>
|
||||||
|
{keyLabelMap[key] ?? key.toUpperCase()}
|
||||||
|
</kbd>
|
||||||
|
{
|
||||||
|
index < (filteredKeys.length - 1) && (
|
||||||
|
<div className={styles['separator']}>
|
||||||
|
{
|
||||||
|
isRange ? t('SETTINGS_SHORTCUT_TO') : '+'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Fragment>
|
||||||
|
))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Keys;
|
||||||
2
src/components/ShortcutsGroup/Combos/Keys/index.ts
Normal file
2
src/components/ShortcutsGroup/Combos/Keys/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
import Keys from './Keys';
|
||||||
|
export default Keys;
|
||||||
2
src/components/ShortcutsGroup/Combos/index.ts
Normal file
2
src/components/ShortcutsGroup/Combos/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
import Combos from './Combos';
|
||||||
|
export default Combos;
|
||||||
44
src/components/ShortcutsGroup/ShortcutsGroup.less
Normal file
44
src/components/ShortcutsGroup/ShortcutsGroup.less
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
.shortcuts-group {
|
||||||
|
flex: 1 1 0;
|
||||||
|
position: relative;
|
||||||
|
min-width: 30rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--primary-foreground-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
.shortcut {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 2rem;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
position: relative;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--primary-foreground-color);
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/components/ShortcutsGroup/ShortcutsGroup.tsx
Normal file
38
src/components/ShortcutsGroup/ShortcutsGroup.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import React from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import Combos from './Combos';
|
||||||
|
import styles from './ShortcutsGroup.less';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string,
|
||||||
|
label: string,
|
||||||
|
shortcuts: Shortcut[],
|
||||||
|
};
|
||||||
|
|
||||||
|
const ShortcutsGroup = ({ className, label, shortcuts }: Props) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classNames(className, styles['shortcuts-group'])}>
|
||||||
|
<div className={styles['title']}>
|
||||||
|
{t(label)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles['shortcuts']}>
|
||||||
|
{
|
||||||
|
shortcuts.map(({ name, label, combos }) => (
|
||||||
|
<div className={styles['shortcut']} key={name}>
|
||||||
|
<div className={styles['label']}>
|
||||||
|
{t(label)}
|
||||||
|
</div>
|
||||||
|
<Combos combos={combos} />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortcutsGroup;
|
||||||
2
src/components/ShortcutsGroup/index.ts
Normal file
2
src/components/ShortcutsGroup/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
import ShortcutsGroup from './ShortcutsGroup';
|
||||||
|
export default ShortcutsGroup;
|
||||||
|
|
@ -12,11 +12,12 @@ const useProfile = require('stremio/common/useProfile');
|
||||||
const VideoPlaceholder = require('./VideoPlaceholder');
|
const VideoPlaceholder = require('./VideoPlaceholder');
|
||||||
const styles = require('./styles');
|
const styles = require('./styles');
|
||||||
|
|
||||||
const Video = React.forwardRef(({ className, id, title, thumbnail, season, episode, released, upcoming, watched, progress, scheduled, seasonWatched, deepLinks, onMarkVideoAsWatched, onMarkSeasonAsWatched, ...props }, ref) => {
|
const Video = ({ className, id, title, thumbnail, season, episode, released, upcoming, watched, progress, scheduled, seasonWatched, selected, deepLinks, onMarkVideoAsWatched, onMarkSeasonAsWatched, ...props }) => {
|
||||||
const routeFocused = useRouteFocused();
|
const routeFocused = useRouteFocused();
|
||||||
const profile = useProfile();
|
const profile = useProfile();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
|
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
|
||||||
|
|
||||||
const popupLabelOnMouseUp = React.useCallback((event) => {
|
const popupLabelOnMouseUp = React.useCallback((event) => {
|
||||||
if (!event.nativeEvent.togglePopupPrevented) {
|
if (!event.nativeEvent.togglePopupPrevented) {
|
||||||
if (event.nativeEvent.ctrlKey || event.nativeEvent.button === 2) {
|
if (event.nativeEvent.ctrlKey || event.nativeEvent.button === 2) {
|
||||||
|
|
@ -68,27 +69,19 @@ const Video = React.forwardRef(({ className, id, title, thumbnail, season, episo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [deepLinks]);
|
}, [deepLinks]);
|
||||||
const renderLabel = React.useMemo(() => function renderLabel({ className, id, title, thumbnail, episode, released, upcoming, watched, progress, scheduled, children, ref: popupRef, ...props }) {
|
const renderLabel = React.useMemo(() => function renderLabel({ className, id, title, thumbnail, episode, released, upcoming, watched, progress, scheduled, children, ref, ...props }) {
|
||||||
const blurThumbnail = profile.settings.hideSpoilers && season && episode && !watched;
|
const blurThumbnail = profile.settings.hideSpoilers && season && episode && !watched;
|
||||||
const handleRef = React.useCallback((node) => {
|
|
||||||
if (popupRef) {
|
React.useEffect(() => {
|
||||||
if (typeof popupRef === 'function') {
|
selected && !watched && ref.current?.scrollIntoView({
|
||||||
popupRef(node);
|
behavior: 'smooth',
|
||||||
} else {
|
block: 'nearest',
|
||||||
popupRef.current = node;
|
inline: 'start'
|
||||||
}
|
});
|
||||||
}
|
}, [selected]);
|
||||||
if (ref) {
|
|
||||||
if (typeof ref === 'function') {
|
|
||||||
ref(node);
|
|
||||||
} else {
|
|
||||||
ref.current = node;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [popupRef]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button {...props} className={classnames(className, styles['video-container'])} title={title} ref={handleRef}>
|
<Button {...props} ref={ref} className={classnames(className, styles['video-container'])} title={title}>
|
||||||
{
|
{
|
||||||
typeof thumbnail === 'string' && thumbnail.length > 0 ?
|
typeof thumbnail === 'string' && thumbnail.length > 0 ?
|
||||||
<div className={styles['thumbnail-container']}>
|
<div className={styles['thumbnail-container']}>
|
||||||
|
|
@ -159,7 +152,7 @@ const Video = React.forwardRef(({ className, id, title, thumbnail, season, episo
|
||||||
{children}
|
{children}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}, []);
|
}, [selected]);
|
||||||
const renderMenu = React.useMemo(() => function renderMenu() {
|
const renderMenu = React.useMemo(() => function renderMenu() {
|
||||||
return (
|
return (
|
||||||
<div className={styles['context-menu-content']} onPointerDown={popupMenuOnPointerDown} onContextMenu={popupMenuOnContextMenu} onClick={popupMenuOnClick} onKeyDown={popupMenuOnKeyDown}>
|
<div className={styles['context-menu-content']} onPointerDown={popupMenuOnPointerDown} onContextMenu={popupMenuOnContextMenu} onClick={popupMenuOnClick} onKeyDown={popupMenuOnKeyDown}>
|
||||||
|
|
@ -203,7 +196,7 @@ const Video = React.forwardRef(({ className, id, title, thumbnail, season, episo
|
||||||
renderMenu={renderMenu}
|
renderMenu={renderMenu}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
Video.Placeholder = VideoPlaceholder;
|
Video.Placeholder = VideoPlaceholder;
|
||||||
|
|
||||||
|
|
@ -220,6 +213,7 @@ Video.propTypes = {
|
||||||
progress: PropTypes.number,
|
progress: PropTypes.number,
|
||||||
scheduled: PropTypes.bool,
|
scheduled: PropTypes.bool,
|
||||||
seasonWatched: PropTypes.bool,
|
seasonWatched: PropTypes.bool,
|
||||||
|
selected: PropTypes.bool,
|
||||||
deepLinks: PropTypes.shape({
|
deepLinks: PropTypes.shape({
|
||||||
metaDetailsStreams: PropTypes.string,
|
metaDetailsStreams: PropTypes.string,
|
||||||
player: PropTypes.string
|
player: PropTypes.string
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import RadioButton from './RadioButton';
|
||||||
import SearchBar from './SearchBar';
|
import SearchBar from './SearchBar';
|
||||||
import SharePrompt from './SharePrompt';
|
import SharePrompt from './SharePrompt';
|
||||||
import Slider from './Slider';
|
import Slider from './Slider';
|
||||||
|
import ShortcutsGroup from './ShortcutsGroup';
|
||||||
import TextInput from './TextInput';
|
import TextInput from './TextInput';
|
||||||
import Toggle from './Toggle';
|
import Toggle from './Toggle';
|
||||||
import Transition from './Transition';
|
import Transition from './Transition';
|
||||||
|
|
@ -59,6 +60,7 @@ export {
|
||||||
SearchBar,
|
SearchBar,
|
||||||
SharePrompt,
|
SharePrompt,
|
||||||
Slider,
|
Slider,
|
||||||
|
ShortcutsGroup,
|
||||||
TextInput,
|
TextInput,
|
||||||
Toggle,
|
Toggle,
|
||||||
Transition,
|
Transition,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ i18n
|
||||||
const root = ReactDOM.createRoot(document.getElementById('app'));
|
const root = ReactDOM.createRoot(document.getElementById('app'));
|
||||||
root.render(<App />);
|
root.render(<App />);
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
if (process.env.NODE_ENV === 'production' && process.env.SERVICE_WORKER_DISABLED !== 'true' && process.env.SERVICE_WORKER_DISABLED !== true && 'serviceWorker' in navigator) {
|
||||||
window.addEventListener('load', () => {
|
window.addEventListener('load', () => {
|
||||||
navigator.serviceWorker.register('service-worker.js')
|
navigator.serviceWorker.register('service-worker.js')
|
||||||
.catch((registrationError) => {
|
.catch((registrationError) => {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,25 @@
|
||||||
|
|
||||||
@import (reference) '~stremio/common/screen-sizes.less';
|
@import (reference) '~stremio/common/screen-sizes.less';
|
||||||
|
|
||||||
|
.disable-cell-items() {
|
||||||
|
.cell {
|
||||||
|
.items {
|
||||||
|
.item {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-items() {
|
||||||
|
.cell {
|
||||||
|
.items {
|
||||||
|
padding: 1px;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.cell {
|
.cell {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -27,12 +46,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
flex: none;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 3rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
padding: 0 1rem;
|
|
||||||
|
|
||||||
.day {
|
.day {
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|
@ -50,12 +66,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.items {
|
.items {
|
||||||
flex: 0 1 10rem;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
gap: 1rem;
|
gap: 0.2rem;
|
||||||
padding: 0 0.5rem 0.5rem 0.5rem;
|
padding: 0.1rem;
|
||||||
|
flex: 1 1 60%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|
@ -64,7 +83,9 @@
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
aspect-ratio: 2 / 3;
|
aspect-ratio: 2 / 3;
|
||||||
border-radius: var(--border-radius);
|
border-radius: calc(var(--border-radius) / 2);
|
||||||
|
max-height: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|
@ -80,13 +101,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.poster {
|
.poster {
|
||||||
flex: auto;
|
height: auto;
|
||||||
z-index: 0;
|
max-height: 100%;
|
||||||
position: relative;
|
aspect-ratio: 2 / 3;
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
opacity: 1;
|
border-radius: inherit
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon, .poster {
|
.icon, .poster {
|
||||||
|
|
@ -117,8 +136,11 @@
|
||||||
|
|
||||||
&.today {
|
&.today {
|
||||||
.heading {
|
.heading {
|
||||||
|
padding: 0.3rem;
|
||||||
.day {
|
.day {
|
||||||
background-color: var(--primary-accent-color);
|
background-color: var(--primary-accent-color);
|
||||||
|
height: 1.5rem;
|
||||||
|
width: 1.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -134,56 +156,55 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-height: @minimum) and (orientation: portrait) {
|
@media only screen and (max-width: @minimum) {
|
||||||
.cell {
|
.disable-cell-items();
|
||||||
.heading {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.items {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.more {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-height: @xxsmall) and (orientation: landscape) {
|
@media @phone-portrait {
|
||||||
|
.cell {
|
||||||
|
flex-direction: column;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.compact-items();
|
||||||
|
.disable-cell-items();
|
||||||
|
}
|
||||||
|
|
||||||
|
@media @phone-landscape {
|
||||||
.cell {
|
.cell {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.items {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.more {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.compact-items();
|
||||||
|
.disable-cell-items();
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (max-height: @xsmall) and (max-width: @xsmall) {
|
@media only screen and (max-height: @medium) and (max-width: @medium) and (orientation: landscape) {
|
||||||
.cell {
|
.cell {
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
height: 2rem;
|
|
||||||
|
|
||||||
.day {
|
.day {
|
||||||
|
padding: 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.items {
|
.items {
|
||||||
padding: 0.25rem;
|
width: 100%;
|
||||||
|
padding-left: 0.5rem;
|
||||||
.item {
|
|
||||||
pointer-events: none;
|
|
||||||
border-radius: calc(var(--border-radius) / 2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: @minimum) and (orientation: portrait) and (pointer: fine) {
|
||||||
|
.cell {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
flex: 1 1 33%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: @small) and (orientation: portrait) {
|
||||||
|
.disable-cell-items();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, 1fr);
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
|
grid-auto-rows: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -138,11 +138,11 @@ const Intro = ({ queryParams }) => {
|
||||||
}, []);
|
}, []);
|
||||||
const loginWithEmail = React.useCallback(() => {
|
const loginWithEmail = React.useCallback(() => {
|
||||||
if (typeof state.email !== 'string' || state.email.length === 0 || !emailRef.current.validity.valid) {
|
if (typeof state.email !== 'string' || state.email.length === 0 || !emailRef.current.validity.valid) {
|
||||||
dispatch({ type: 'error', error: 'Invalid email' });
|
dispatch({ type: 'error', error: t('INVALID_EMAIL') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof state.password !== 'string' || state.password.length === 0) {
|
if (typeof state.password !== 'string' || state.password.length === 0) {
|
||||||
dispatch({ type: 'error', error: 'Invalid password' });
|
dispatch({ type: 'error', error: t('INVALID_PASSWORD') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openLoaderModal();
|
openLoaderModal();
|
||||||
|
|
@ -160,26 +160,26 @@ const Intro = ({ queryParams }) => {
|
||||||
}, [state.email, state.password]);
|
}, [state.email, state.password]);
|
||||||
const loginAsGuest = React.useCallback(() => {
|
const loginAsGuest = React.useCallback(() => {
|
||||||
if (!state.termsAccepted) {
|
if (!state.termsAccepted) {
|
||||||
dispatch({ type: 'error', error: 'You must accept the Terms of Service' });
|
dispatch({ type: 'error', error: t('MUST_ACCEPT_TERMS') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location = '#/';
|
window.location = '#/';
|
||||||
}, [state.termsAccepted]);
|
}, [state.termsAccepted]);
|
||||||
const signup = React.useCallback(() => {
|
const signup = React.useCallback(() => {
|
||||||
if (typeof state.email !== 'string' || state.email.length === 0 || !emailRef.current.validity.valid) {
|
if (typeof state.email !== 'string' || state.email.length === 0 || !emailRef.current.validity.valid) {
|
||||||
dispatch({ type: 'error', error: 'Invalid email' });
|
dispatch({ type: 'error', error: t('INVALID_EMAIL') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof state.password !== 'string' || state.password.length === 0) {
|
if (typeof state.password !== 'string' || state.password.length === 0) {
|
||||||
dispatch({ type: 'error', error: 'Invalid password' });
|
dispatch({ type: 'error', error: t('INVALID_PASSWORD') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.password !== state.confirmPassword) {
|
if (state.password !== state.confirmPassword) {
|
||||||
dispatch({ type: 'error', error: 'Passwords do not match' });
|
dispatch({ type: 'error', error: t('PASSWORDS_NOMATCH') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!state.termsAccepted) {
|
if (!state.termsAccepted) {
|
||||||
dispatch({ type: 'error', error: 'You must accept the Terms of Service' });
|
dispatch({ type: 'error', error: t('MUST_ACCEPT_TERMS') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!state.privacyPolicyAccepted) {
|
if (!state.privacyPolicyAccepted) {
|
||||||
|
|
@ -387,7 +387,7 @@ const Intro = ({ queryParams }) => {
|
||||||
{
|
{
|
||||||
state.form === SIGNUP_FORM ?
|
state.form === SIGNUP_FORM ?
|
||||||
<Button className={classnames(styles['form-button'], styles['login-form-button'])} onClick={switchFormOnClick}>
|
<Button className={classnames(styles['form-button'], styles['login-form-button'])} onClick={switchFormOnClick}>
|
||||||
<div className={classnames(styles['label'], styles['uppercase'])}>{t('LOG_IN')}</div>
|
<div className={styles['label']}>{t('LOG_IN')}</div>
|
||||||
</Button>
|
</Button>
|
||||||
:
|
:
|
||||||
null
|
null
|
||||||
|
|
@ -395,7 +395,7 @@ const Intro = ({ queryParams }) => {
|
||||||
{
|
{
|
||||||
state.form === LOGIN_FORM ?
|
state.form === LOGIN_FORM ?
|
||||||
<Button className={classnames(styles['form-button'], styles['signup-form-button'])} onClick={switchFormOnClick}>
|
<Button className={classnames(styles['form-button'], styles['signup-form-button'])} onClick={switchFormOnClick}>
|
||||||
<div className={classnames(styles['label'], styles['uppercase'])}>{t('SIGN_UP_EMAIL')}</div>
|
<div className={styles['label']}>{t('SIGN_UP_EMAIL')}</div>
|
||||||
</Button>
|
</Button>
|
||||||
:
|
:
|
||||||
null
|
null
|
||||||
|
|
@ -403,7 +403,7 @@ const Intro = ({ queryParams }) => {
|
||||||
{
|
{
|
||||||
state.form === SIGNUP_FORM ?
|
state.form === SIGNUP_FORM ?
|
||||||
<Button className={classnames(styles['form-button'], styles['guest-login-button'])} onClick={loginAsGuest}>
|
<Button className={classnames(styles['form-button'], styles['guest-login-button'])} onClick={loginAsGuest}>
|
||||||
<div className={classnames(styles['label'], styles['uppercase'])}>{t('GUEST_LOGIN')}</div>
|
<div className={styles['label']}>{t('GUEST_LOGIN')}</div>
|
||||||
</Button>
|
</Button>
|
||||||
:
|
:
|
||||||
null
|
null
|
||||||
|
|
|
||||||
|
|
@ -101,10 +101,6 @@
|
||||||
color: var(--primary-foreground-color);
|
color: var(--primary-foreground-color);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.uppercase {
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.submit-button, .guest-login-button, .signup-form-button, .login-form-button {
|
.submit-button, .guest-login-button, .signup-form-button, .login-form-button {
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ const useAppleLogin = (): [() => Promise<AppleLoginResponse>, () => void] => {
|
||||||
timeout.current && clearTimeout(timeout.current);
|
timeout.current && clearTimeout(timeout.current);
|
||||||
timeout.current = setTimeout(() => {
|
timeout.current = setTimeout(() => {
|
||||||
if (tries >= MAX_TRIES)
|
if (tries >= MAX_TRIES)
|
||||||
return reject(new Error('Failed to authenticate with Apple'));
|
return reject(new Error('Failed to authenticate with Apple', { cause: 'Number of allowed tries exceeded!' }));
|
||||||
|
|
||||||
tries++;
|
tries++;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ const useFacebookLogin = () => {
|
||||||
timeout.current && clearTimeout(timeout.current);
|
timeout.current && clearTimeout(timeout.current);
|
||||||
timeout.current = setTimeout(() => {
|
timeout.current = setTimeout(() => {
|
||||||
if (tries >= MAX_TRIES)
|
if (tries >= MAX_TRIES)
|
||||||
return reject(new Error('Failed to authenticate with facebook'));
|
return reject(new Error('Failed to authenticate with facebook', { cause: 'Number of allowed tries exceeded!' }));
|
||||||
|
|
||||||
tries++;
|
tries++;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,7 @@ const MetaDetails = ({ urlParams, queryParams }) => {
|
||||||
metaItem={metaDetails.metaItem}
|
metaItem={metaDetails.metaItem}
|
||||||
libraryItem={metaDetails.libraryItem}
|
libraryItem={metaDetails.libraryItem}
|
||||||
season={season}
|
season={season}
|
||||||
|
selectedVideoId={metaDetails.libraryItem?.state?.video_id}
|
||||||
seasonOnSelect={seasonOnSelect}
|
seasonOnSelect={seasonOnSelect}
|
||||||
toggleNotifications={toggleNotifications}
|
toggleNotifications={toggleNotifications}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,10 @@ const SeasonsBar = require('./SeasonsBar');
|
||||||
const { default: EpisodePicker } = require('../EpisodePicker');
|
const { default: EpisodePicker } = require('../EpisodePicker');
|
||||||
const styles = require('./styles');
|
const styles = require('./styles');
|
||||||
|
|
||||||
const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect, toggleNotifications }) => {
|
const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect, selectedVideoId, toggleNotifications }) => {
|
||||||
const { core } = useServices();
|
const { core } = useServices();
|
||||||
const profile = useProfile();
|
const profile = useProfile();
|
||||||
|
|
||||||
const showNotificationsToggle = React.useMemo(() => {
|
const showNotificationsToggle = React.useMemo(() => {
|
||||||
return metaItem?.content?.content?.inLibrary && metaItem?.content?.content?.videos?.length;
|
return metaItem?.content?.content?.inLibrary && metaItem?.content?.content?.videos?.length;
|
||||||
}, [metaItem]);
|
}, [metaItem]);
|
||||||
|
|
@ -178,6 +179,7 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
|
||||||
deepLinks={video.deepLinks}
|
deepLinks={video.deepLinks}
|
||||||
scheduled={video.scheduled}
|
scheduled={video.scheduled}
|
||||||
seasonWatched={seasonWatched}
|
seasonWatched={seasonWatched}
|
||||||
|
selected={video.id === selectedVideoId}
|
||||||
onMarkVideoAsWatched={onMarkVideoAsWatched}
|
onMarkVideoAsWatched={onMarkVideoAsWatched}
|
||||||
onMarkSeasonAsWatched={onMarkSeasonAsWatched}
|
onMarkSeasonAsWatched={onMarkSeasonAsWatched}
|
||||||
/>
|
/>
|
||||||
|
|
@ -195,6 +197,7 @@ VideosList.propTypes = {
|
||||||
metaItem: PropTypes.object,
|
metaItem: PropTypes.object,
|
||||||
libraryItem: PropTypes.object,
|
libraryItem: PropTypes.object,
|
||||||
season: PropTypes.number,
|
season: PropTypes.number,
|
||||||
|
selectedVideoId: PropTypes.string,
|
||||||
seasonOnSelect: PropTypes.func,
|
seasonOnSelect: PropTypes.func,
|
||||||
toggleNotifications: PropTypes.func,
|
toggleNotifications: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const langs = require('langs');
|
||||||
const { useTranslation } = require('react-i18next');
|
const { useTranslation } = require('react-i18next');
|
||||||
const { useRouteFocused } = require('stremio-router');
|
const { useRouteFocused } = require('stremio-router');
|
||||||
const { useServices } = require('stremio/services');
|
const { useServices } = require('stremio/services');
|
||||||
const { onFileDrop, useSettings, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS, useShell } = require('stremio/common');
|
const { onFileDrop, useSettings, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS, useShell, usePlatform } = require('stremio/common');
|
||||||
const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components');
|
const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components');
|
||||||
const BufferingLoader = require('./BufferingLoader');
|
const BufferingLoader = require('./BufferingLoader');
|
||||||
const VolumeChangeIndicator = require('./VolumeChangeIndicator');
|
const VolumeChangeIndicator = require('./VolumeChangeIndicator');
|
||||||
|
|
@ -43,6 +43,7 @@ const Player = ({ urlParams, queryParams }) => {
|
||||||
const statistics = useStatistics(player, streamingServer);
|
const statistics = useStatistics(player, streamingServer);
|
||||||
const video = useVideo();
|
const video = useVideo();
|
||||||
const routeFocused = useRouteFocused();
|
const routeFocused = useRouteFocused();
|
||||||
|
const platform = usePlatform();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const [seeking, setSeeking] = React.useState(false);
|
const [seeking, setSeeking] = React.useState(false);
|
||||||
|
|
@ -346,6 +347,8 @@ const Player = ({ urlParams, queryParams }) => {
|
||||||
forceTranscoding: forceTranscoding || casting,
|
forceTranscoding: forceTranscoding || casting,
|
||||||
maxAudioChannels: settings.surroundSound ? 32 : 2,
|
maxAudioChannels: settings.surroundSound ? 32 : 2,
|
||||||
hardwareDecoding: settings.hardwareDecoding,
|
hardwareDecoding: settings.hardwareDecoding,
|
||||||
|
videoMode: settings.videoMode,
|
||||||
|
platform: platform.name,
|
||||||
streamingServerURL: streamingServer.baseUrl ?
|
streamingServerURL: streamingServer.baseUrl ?
|
||||||
casting ?
|
casting ?
|
||||||
streamingServer.baseUrl
|
streamingServer.baseUrl
|
||||||
|
|
@ -533,6 +536,53 @@ const Player = ({ urlParams, queryParams }) => {
|
||||||
}
|
}
|
||||||
}, [settings.pauseOnMinimize, shell.windowClosed, shell.windowHidden]);
|
}, [settings.pauseOnMinimize, shell.windowClosed, shell.windowHidden]);
|
||||||
|
|
||||||
|
// Media Session PlaybackState
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!navigator.mediaSession) return;
|
||||||
|
|
||||||
|
const playbackState = !video.state.paused ? 'playing' : 'paused';
|
||||||
|
navigator.mediaSession.playbackState = playbackState;
|
||||||
|
|
||||||
|
return () => navigator.mediaSession.playbackState = 'none';
|
||||||
|
}, [video.state.paused]);
|
||||||
|
|
||||||
|
// Media Session Metadata
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!navigator.mediaSession) return;
|
||||||
|
|
||||||
|
const metaItem = player.metaItem && player.metaItem?.type === 'Ready' ? player.metaItem.content : null;
|
||||||
|
const videoId = player.selected ? player.selected?.streamRequest?.path?.id : null;
|
||||||
|
const video = metaItem ? metaItem.videos.find(({ id }) => id === videoId) : null;
|
||||||
|
|
||||||
|
const videoInfo = video && video.season && video.episode ? ` (${video.season}x${video.episode})`: null;
|
||||||
|
const videoTitle = video ? `${video.title}${videoInfo}` : null;
|
||||||
|
const metaTitle = metaItem ? metaItem.name : null;
|
||||||
|
const imageUrl = metaItem ? metaItem.logo : null;
|
||||||
|
|
||||||
|
const title = videoTitle ?? metaTitle;
|
||||||
|
const artist = videoTitle ? metaTitle : undefined;
|
||||||
|
const artwork = imageUrl ? [{ src: imageUrl }] : undefined;
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
artwork,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [player.metaItem, player.selected]);
|
||||||
|
|
||||||
|
// Media Session Actions
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!navigator.mediaSession) return;
|
||||||
|
|
||||||
|
navigator.mediaSession.setActionHandler('play', onPlayRequested);
|
||||||
|
navigator.mediaSession.setActionHandler('pause', onPauseRequested);
|
||||||
|
|
||||||
|
const nexVideoCallback = player.nextVideo ? onNextVideoRequested : null;
|
||||||
|
navigator.mediaSession.setActionHandler('nexttrack', nexVideoCallback);
|
||||||
|
}, [player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested]);
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
const onKeyDown = (event) => {
|
const onKeyDown = (event) => {
|
||||||
switch (event.code) {
|
switch (event.code) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Copyright (C) 2017-2024 Smart code 203358507
|
// Copyright (C) 2017-2024 Smart code 203358507
|
||||||
|
|
||||||
import React, { useMemo, useCallback, useState, forwardRef, memo, useRef } from 'react';
|
import React, { useMemo, useCallback, useState, forwardRef, memo } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Icon from '@stremio/stremio-icons/react';
|
import Icon from '@stremio/stremio-icons/react';
|
||||||
import { useServices } from 'stremio/services';
|
import { useServices } from 'stremio/services';
|
||||||
|
|
@ -21,7 +21,8 @@ type Props = {
|
||||||
const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, className, closeSideDrawer, selected, ...props }: Props, ref) => {
|
const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, className, closeSideDrawer, selected, ...props }: Props, ref) => {
|
||||||
const { core } = useServices();
|
const { core } = useServices();
|
||||||
const [season, setSeason] = useState<number>(seriesInfo?.season);
|
const [season, setSeason] = useState<number>(seriesInfo?.season);
|
||||||
const selectedVideoRef = useRef<HTMLDivElement>(null);
|
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
|
||||||
|
|
||||||
const metaItem = useMemo(() => {
|
const metaItem = useMemo(() => {
|
||||||
return seriesInfo ?
|
return seriesInfo ?
|
||||||
{
|
{
|
||||||
|
|
@ -78,11 +79,9 @@ const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, classNa
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTransitionEnd = () => {
|
const onTransitionEnd = useCallback(() => {
|
||||||
selectedVideoRef.current?.scrollIntoView({
|
setSelectedVideoId(selected);
|
||||||
behavior: 'smooth',
|
}, [selected]);
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className={classNames(styles['side-drawer'], className)} onMouseDown={onMouseDown} onTransitionEnd={onTransitionEnd}>
|
<div ref={ref} className={classNames(styles['side-drawer'], className)} onMouseDown={onMouseDown} onTransitionEnd={onTransitionEnd}>
|
||||||
|
|
@ -114,7 +113,6 @@ const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, classNa
|
||||||
{videos.map((video, index) => (
|
{videos.map((video, index) => (
|
||||||
<Video
|
<Video
|
||||||
key={index}
|
key={index}
|
||||||
ref={video.id === selected ? selectedVideoRef : null}
|
|
||||||
className={styles['video']}
|
className={styles['video']}
|
||||||
id={video.id}
|
id={video.id}
|
||||||
title={video.title}
|
title={video.title}
|
||||||
|
|
@ -128,6 +126,7 @@ const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, classNa
|
||||||
progress={video.progress}
|
progress={video.progress}
|
||||||
deepLinks={video.deepLinks}
|
deepLinks={video.deepLinks}
|
||||||
scheduled={video.scheduled}
|
scheduled={video.scheduled}
|
||||||
|
selected={video.id === selectedVideoId}
|
||||||
onMarkVideoAsWatched={onMarkVideoAsWatched}
|
onMarkVideoAsWatched={onMarkVideoAsWatched}
|
||||||
onMarkSeasonAsWatched={onMarkSeasonAsWatched}
|
onMarkSeasonAsWatched={onMarkSeasonAsWatched}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,7 @@ const SubtitlesMenu = React.memo((props) => {
|
||||||
/>
|
/>
|
||||||
<Stepper
|
<Stepper
|
||||||
className={styles['stepper']}
|
className={styles['stepper']}
|
||||||
label={'PLAYER_SUBTITLES_VERTICAL_POSIITON'}
|
label={'PLAYER_SUBTITLES_VERTICAL_POSITION'}
|
||||||
value={props.selectedSubtitlesTrackId ? props.subtitlesOffset : props.selectedExtraSubtitlesTrackId ? props.extraSubtitlesOffset : null}
|
value={props.selectedSubtitlesTrackId ? props.subtitlesOffset : props.selectedExtraSubtitlesTrackId ? props.extraSubtitlesOffset : null}
|
||||||
unit={'%'}
|
unit={'%'}
|
||||||
step={1}
|
step={1}
|
||||||
|
|
|
||||||
|
|
@ -152,7 +152,15 @@ const useVideo = () => {
|
||||||
video.current.on('extraSubtitlesTrackLoaded', onExtraSubtitlesTrackLoaded);
|
video.current.on('extraSubtitlesTrackLoaded', onExtraSubtitlesTrackLoaded);
|
||||||
video.current.on('extraSubtitlesTrackAdded', onExtraSubtitlesTrackAdded);
|
video.current.on('extraSubtitlesTrackAdded', onExtraSubtitlesTrackAdded);
|
||||||
|
|
||||||
return () => video.current.destroy();
|
return () => {
|
||||||
|
if (video.current) {
|
||||||
|
try {
|
||||||
|
video.current.destroy();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error destroying video:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { ColorInput, MultiselectMenu, Toggle } from 'stremio/components';
|
||||||
import { useServices } from 'stremio/services';
|
import { useServices } from 'stremio/services';
|
||||||
import { Category, Option, Section } from '../components';
|
import { Category, Option, Section } from '../components';
|
||||||
import usePlayerOptions from './usePlayerOptions';
|
import usePlayerOptions from './usePlayerOptions';
|
||||||
|
import { usePlatform } from 'stremio/common';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
profile: Profile,
|
profile: Profile,
|
||||||
|
|
@ -10,6 +11,7 @@ type Props = {
|
||||||
|
|
||||||
const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
|
const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
|
||||||
const { shell } = useServices();
|
const { shell } = useServices();
|
||||||
|
const platform = usePlatform();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
subtitlesLanguageSelect,
|
subtitlesLanguageSelect,
|
||||||
|
|
@ -26,6 +28,7 @@ const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
|
||||||
bingeWatchingToggle,
|
bingeWatchingToggle,
|
||||||
playInBackgroundToggle,
|
playInBackgroundToggle,
|
||||||
hardwareDecodingToggle,
|
hardwareDecodingToggle,
|
||||||
|
videoModeSelect,
|
||||||
pauseOnMinimizeToggle,
|
pauseOnMinimizeToggle,
|
||||||
} = usePlayerOptions(profile);
|
} = usePlayerOptions(profile);
|
||||||
|
|
||||||
|
|
@ -129,6 +132,15 @@ const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
|
||||||
/>
|
/>
|
||||||
</Option>
|
</Option>
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
shell.active && platform.name === 'windows' &&
|
||||||
|
<Option label={'SETTINGS_VIDEO_MODE'}>
|
||||||
|
<MultiselectMenu
|
||||||
|
className={'multiselect'}
|
||||||
|
{...videoModeSelect}
|
||||||
|
/>
|
||||||
|
</Option>
|
||||||
|
}
|
||||||
{
|
{
|
||||||
shell.active &&
|
shell.active &&
|
||||||
<Option label={'SETTINGS_PAUSE_MINIMIZED'}>
|
<Option label={'SETTINGS_PAUSE_MINIMIZED'}>
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,38 @@ const usePlayerOptions = (profile: Profile) => {
|
||||||
}
|
}
|
||||||
}), [profile.settings]);
|
}), [profile.settings]);
|
||||||
|
|
||||||
|
const videoModeSelect = useMemo(() => ({
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: null,
|
||||||
|
label: t('SETTINGS_VIDEO_MODE_DEFAULT'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'legacy',
|
||||||
|
label: t('SETTINGS_VIDEO_MODE_LEGACY'),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
value: profile.settings.videoMode,
|
||||||
|
title: () => {
|
||||||
|
return profile.settings.videoMode === 'legacy' ?
|
||||||
|
t('SETTINGS_VIDEO_MODE_LEGACY')
|
||||||
|
:
|
||||||
|
t('SETTINGS_VIDEO_MODE_DEFAULT');
|
||||||
|
},
|
||||||
|
onSelect: (value: string | null) => {
|
||||||
|
core.transport.dispatch({
|
||||||
|
action: 'Ctx',
|
||||||
|
args: {
|
||||||
|
action: 'UpdateSettings',
|
||||||
|
args: {
|
||||||
|
...profile.settings,
|
||||||
|
videoMode: value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}), [profile.settings]);
|
||||||
|
|
||||||
const pauseOnMinimizeToggle = useMemo(() => ({
|
const pauseOnMinimizeToggle = useMemo(() => ({
|
||||||
checked: profile.settings.pauseOnMinimize,
|
checked: profile.settings.pauseOnMinimize,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
|
|
@ -318,6 +350,7 @@ const usePlayerOptions = (profile: Profile) => {
|
||||||
bingeWatchingToggle,
|
bingeWatchingToggle,
|
||||||
playInBackgroundToggle,
|
playInBackgroundToggle,
|
||||||
hardwareDecodingToggle,
|
hardwareDecodingToggle,
|
||||||
|
videoModeSelect,
|
||||||
pauseOnMinimizeToggle,
|
pauseOnMinimizeToggle,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,4 @@
|
||||||
.shortcut-container {
|
.shortcuts-group {
|
||||||
display: flex;
|
width: 100%;
|
||||||
align-items: center;
|
margin-bottom: 3rem;
|
||||||
justify-content: center;
|
|
||||||
padding: 0;
|
|
||||||
overflow: visible;
|
|
||||||
|
|
||||||
kbd {
|
|
||||||
flex: 0 1 auto;
|
|
||||||
height: 2.5rem;
|
|
||||||
min-width: 2.5rem;
|
|
||||||
line-height: 2.5rem;
|
|
||||||
padding: 0 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--primary-foreground-color);
|
|
||||||
border-radius: 0.25em;
|
|
||||||
box-shadow: 0 4px 0 1px var(--modal-background-color);
|
|
||||||
background-color: var(--overlay-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.label {
|
|
||||||
flex: none;
|
|
||||||
margin: 0 1rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--primary-foreground-color);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,97 +1,24 @@
|
||||||
import React, { forwardRef } from 'react';
|
import React, { forwardRef } from 'react';
|
||||||
import { Section, Option } from '../components';
|
import { Section } from '../components';
|
||||||
|
import { ShortcutsGroup } from 'stremio/components';
|
||||||
|
import { useShortcuts } from 'stremio/common';
|
||||||
import styles from './Shortcuts.less';
|
import styles from './Shortcuts.less';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
|
|
||||||
const Shortcuts = forwardRef<HTMLDivElement>((_, ref) => {
|
const Shortcuts = forwardRef<HTMLDivElement>((_, ref) => {
|
||||||
const { t } = useTranslation();
|
const { grouped } = useShortcuts();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section ref={ref} label={'SETTINGS_NAV_SHORTCUTS'}>
|
<Section ref={ref} label={'SETTINGS_NAV_SHORTCUTS'}>
|
||||||
<Option label={'SETTINGS_SHORTCUT_PLAY_PAUSE'}>
|
{
|
||||||
<div className={styles['shortcut-container']}>
|
grouped.map(({ name, label, shortcuts }) => (
|
||||||
<kbd>{t('SETTINGS_SHORTCUT_SPACE')}</kbd>
|
<ShortcutsGroup
|
||||||
</div>
|
key={name}
|
||||||
</Option>
|
className={styles['shortcuts-group']}
|
||||||
<Option label={'SETTINGS_SHORTCUT_SEEK_FORWARD'}>
|
label={label}
|
||||||
<div className={styles['shortcut-container']}>
|
shortcuts={shortcuts}
|
||||||
<kbd>→</kbd>
|
/>
|
||||||
<div className={styles['label']}>{t('SETTINGS_SHORTCUT_OR')}</div>
|
))
|
||||||
<kbd>⇧ {t('SETTINGS_SHORTCUT_SHIFT')}</kbd>
|
}
|
||||||
<div className={styles['label']}>+</div>
|
|
||||||
<kbd>→</kbd>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
<Option label={'SETTINGS_SHORTCUT_SEEK_BACKWARD'}>
|
|
||||||
<div className={styles['shortcut-container']}>
|
|
||||||
<kbd>←</kbd>
|
|
||||||
<div className={styles['label']}>{t('SETTINGS_SHORTCUT_OR')}</div>
|
|
||||||
<kbd>⇧ {t('SETTINGS_SHORTCUT_SHIFT')}</kbd>
|
|
||||||
<div className={styles['label']}>+</div>
|
|
||||||
<kbd>←</kbd>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
<Option label={'SETTINGS_SHORTCUT_VOLUME_UP'}>
|
|
||||||
<div className={styles['shortcut-container']}>
|
|
||||||
<kbd>↑</kbd>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
<Option label={'SETTINGS_SHORTCUT_VOLUME_DOWN'}>
|
|
||||||
<div className={styles['shortcut-container']}>
|
|
||||||
<kbd>↓</kbd>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
<Option label={'SETTINGS_SHORTCUT_MENU_SUBTITLES'}>
|
|
||||||
<div className={styles['shortcut-container']}>
|
|
||||||
<kbd>S</kbd>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
<Option label={'SETTINGS_SHORTCUT_MENU_AUDIO'}>
|
|
||||||
<div className={styles['shortcut-container']}>
|
|
||||||
<kbd>A</kbd>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
<Option label={'SETTINGS_SHORTCUT_MENU_INFO'}>
|
|
||||||
<div className={styles['shortcut-container']}>
|
|
||||||
<kbd>I</kbd>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
<Option label={'SETTINGS_SHORTCUT_FULLSCREEN'}>
|
|
||||||
<div className={styles['shortcut-container']}>
|
|
||||||
<kbd>F</kbd>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
<Option label={'SETTINGS_SHORTCUT_SUBTITLES_SIZE'}>
|
|
||||||
<div className={styles['shortcut-container']}>
|
|
||||||
<kbd>-</kbd>
|
|
||||||
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_AND') }</div>
|
|
||||||
<kbd>=</kbd>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
<Option label={'SETTINGS_SHORTCUT_SUBTITLES_DELAY'}>
|
|
||||||
<div className={styles['shortcut-container']}>
|
|
||||||
<kbd>G</kbd>
|
|
||||||
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_AND') }</div>
|
|
||||||
<kbd>H</kbd>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
<Option label={'SETTINGS_SHORTCUT_NAVIGATE_MENUS'}>
|
|
||||||
<div className={styles['shortcut-container']}>
|
|
||||||
<kbd>1</kbd>
|
|
||||||
<div className={styles['label']}>{t('SETTINGS_SHORTCUT_TO')}</div>
|
|
||||||
<kbd>6</kbd>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
<Option label={'SETTINGS_SHORTCUT_GO_TO_SEARCH'}>
|
|
||||||
<div className={styles['shortcut-container']}>
|
|
||||||
<kbd>0</kbd>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
<Option label={'SETTINGS_SHORTCUT_EXIT_BACK'}>
|
|
||||||
<div className={styles['shortcut-container']}>
|
|
||||||
<kbd>{t('SETTINGS_SHORTCUT_ESC')}</kbd>
|
|
||||||
</div>
|
|
||||||
</Option>
|
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ const AddItem = ({ onCancel, handleAddUrl }: Props) => {
|
||||||
setInputValue(target.value);
|
setInputValue(target.value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onSumbit = useCallback(() => {
|
const onSubmit = useCallback(() => {
|
||||||
handleAddUrl(inputValue);
|
handleAddUrl(inputValue);
|
||||||
}, [inputValue]);
|
}, [inputValue]);
|
||||||
|
|
||||||
|
|
@ -27,11 +27,11 @@ const AddItem = ({ onCancel, handleAddUrl }: Props) => {
|
||||||
className={styles['input']}
|
className={styles['input']}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={handleValueChange}
|
onChange={handleValueChange}
|
||||||
onSubmit={onSumbit}
|
onSubmit={onSubmit}
|
||||||
placeholder={'Enter URL'}
|
placeholder={'Enter URL'}
|
||||||
/>
|
/>
|
||||||
<div className={styles['actions']}>
|
<div className={styles['actions']}>
|
||||||
<Button className={styles['add']} onClick={onSumbit}>
|
<Button className={styles['add']} onClick={onSubmit}>
|
||||||
<Icon name={'checkmark'} className={styles['icon']} />
|
<Icon name={'checkmark'} className={styles['icon']} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button className={styles['cancel']} onClick={onCancel}>
|
<Button className={styles['cancel']} onClick={onCancel}>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ const initialize = () => {
|
||||||
if (castAPIAvailable) {
|
if (castAPIAvailable) {
|
||||||
resolve();
|
resolve();
|
||||||
} else {
|
} else {
|
||||||
reject(new Error('window.cast api not available'));
|
reject(new Error('window.cast api not available', { cause: 'castAPIAvailable is null.' }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (castAPIAvailable !== null) {
|
if (castAPIAvailable !== null) {
|
||||||
|
|
@ -167,7 +167,7 @@ function ChromecastTransport() {
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
return Promise.reject(new Error('Session not started'));
|
return Promise.reject(new Error('Session not started', { cause: 'castSession is null.' }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ function Shell() {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
active = false;
|
active = false;
|
||||||
error = new Error(e);
|
error = new Error('Failed to initialize shell transport', { cause: e });
|
||||||
starting = false;
|
starting = false;
|
||||||
onStateChanged();
|
onStateChanged();
|
||||||
transport = null;
|
transport = null;
|
||||||
|
|
|
||||||
4
src/types/global.d.ts
vendored
4
src/types/global.d.ts
vendored
|
|
@ -1,5 +1,3 @@
|
||||||
/* eslint-disable no-var */
|
|
||||||
|
|
||||||
type QtTransportMessage = {
|
type QtTransportMessage = {
|
||||||
data: string;
|
data: string;
|
||||||
};
|
};
|
||||||
|
|
@ -28,4 +26,4 @@ declare global {
|
||||||
var chrome: Chrome | undefined;
|
var chrome: Chrome | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export { };
|
||||||
|
|
|
||||||
1
src/types/models/Ctx.d.ts
vendored
1
src/types/models/Ctx.d.ts
vendored
|
|
@ -19,6 +19,7 @@ type Settings = {
|
||||||
autoFrameRateMatching: boolean,
|
autoFrameRateMatching: boolean,
|
||||||
bingeWatching: boolean,
|
bingeWatching: boolean,
|
||||||
hardwareDecoding: boolean,
|
hardwareDecoding: boolean,
|
||||||
|
videoMode: string | null,
|
||||||
escExitFullscreen: boolean,
|
escExitFullscreen: boolean,
|
||||||
interfaceLanguage: string,
|
interfaceLanguage: string,
|
||||||
quitOnClose: boolean,
|
quitOnClose: boolean,
|
||||||
|
|
|
||||||
2
src/types/models/Player.d.ts
vendored
2
src/types/models/Player.d.ts
vendored
|
|
@ -3,7 +3,7 @@ type LibraryItemPlayer = Pick<LibraryItem, '_id'> & {
|
||||||
};
|
};
|
||||||
|
|
||||||
type VideoPlayer = Video & {
|
type VideoPlayer = Video & {
|
||||||
upcomming: boolean,
|
upcoming: boolean,
|
||||||
watched: boolean,
|
watched: boolean,
|
||||||
progress: boolean | null,
|
progress: boolean | null,
|
||||||
scheduled: boolean,
|
scheduled: boolean,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["DOM", "DOM.Iterable"],
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
"jsx": "react",
|
"jsx": "react",
|
||||||
"baseUrl": "./src",
|
"baseUrl": "./src",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ const WorkboxPlugin = require('workbox-webpack-plugin');
|
||||||
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
const CopyWebpackPlugin = require('copy-webpack-plugin');
|
||||||
const TerserPlugin = require('terser-webpack-plugin');
|
const TerserPlugin = require('terser-webpack-plugin');
|
||||||
const WebpackPwaManifest = require('webpack-pwa-manifest');
|
const WebpackPwaManifest = require('webpack-pwa-manifest');
|
||||||
const pachageJson = require('./package.json');
|
const packageJson = require('./package.json');
|
||||||
|
|
||||||
const COMMIT_HASH = execSync('git rev-parse HEAD').toString().trim();
|
const COMMIT_HASH = execSync('git rev-parse HEAD').toString().trim();
|
||||||
|
|
||||||
|
|
@ -213,8 +213,9 @@ module.exports = (env, argv) => ({
|
||||||
new webpack.EnvironmentPlugin({
|
new webpack.EnvironmentPlugin({
|
||||||
SENTRY_DSN: null,
|
SENTRY_DSN: null,
|
||||||
...env,
|
...env,
|
||||||
|
SERVICE_WORKER_DISABLED: false,
|
||||||
DEBUG: argv.mode !== 'production',
|
DEBUG: argv.mode !== 'production',
|
||||||
VERSION: pachageJson.version,
|
VERSION: packageJson.version,
|
||||||
COMMIT_HASH
|
COMMIT_HASH
|
||||||
}),
|
}),
|
||||||
new webpack.ProvidePlugin({
|
new webpack.ProvidePlugin({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue