Merge branch 'development' into fix/meta-details-path-for-episode-picker
4
.github/workflows/build.yml
vendored
|
|
@ -20,14 +20,14 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- 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
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: "pnpm"
|
||||
|
|
|
|||
2
.github/workflows/pages_cleanup.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: gh-pages
|
||||
fetch-depth: 0
|
||||
|
|
|
|||
11
.github/workflows/release.yml
vendored
|
|
@ -9,8 +9,13 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
- name: Install NPM dependencies
|
||||
uses: actions/checkout@v6
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Build
|
||||
env:
|
||||
|
|
@ -19,7 +24,7 @@ jobs:
|
|||
- name: Zip build artifact
|
||||
run: zip -r stremio-web.zip ./build
|
||||
- name: Upload build artifact to GitHub release assets
|
||||
uses: svenstaro/upload-release-action@2.11.2
|
||||
uses: svenstaro/upload-release-action@2.11.3
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: stremio-web.zip
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ Project maintainers are responsible for enforcing this code of conduct. They can
|
|||
## Suggestions for newbies
|
||||
|
||||
- Contributors are welcomed to use AI models as "help" in solving issues, but you must always double check the code that you're submitting.
|
||||
- Refrain from excesive comments generated by AI.
|
||||
- Refrain from excessive comments generated by AI.
|
||||
- Refrain from docs generated entirely by AI.
|
||||
- Always check what files you are committing and submitting to the PR when you are using any agent for help or an AI model.
|
||||
- If you don't know how to tackle a problem and AI can't help you, please just ask or look in Stack Overlflow, Google, Medium etc.
|
||||
|
|
|
|||
38
Dockerfile
|
|
@ -3,29 +3,39 @@
|
|||
ARG NODE_VERSION=20-alpine
|
||||
FROM node:$NODE_VERSION AS base
|
||||
|
||||
# Setup pnpm
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# Meta
|
||||
LABEL Description="Stremio Web" Vendor="Smart Code OOD" Version="1.0.0"
|
||||
|
||||
RUN mkdir -p /var/www/stremio-web
|
||||
WORKDIR /var/www/stremio-web
|
||||
|
||||
# Install app dependencies
|
||||
FROM base AS prebuild
|
||||
# Setup app
|
||||
FROM base AS app
|
||||
|
||||
RUN apk update && apk upgrade && \
|
||||
apk add --no-cache git
|
||||
WORKDIR /var/www/stremio-web
|
||||
COPY . .
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
COPY package.json pnpm-lock.yaml /var/www/stremio-web
|
||||
RUN pnpm i --frozen-lockfile
|
||||
|
||||
# Bundle app source
|
||||
FROM base AS final
|
||||
COPY . /var/www/stremio-web
|
||||
RUN pnpm build
|
||||
|
||||
WORKDIR /var/www/stremio-web
|
||||
COPY . .
|
||||
COPY --from=prebuild /var/www/stremio-web/node_modules ./node_modules
|
||||
COPY --from=prebuild /var/www/stremio-web/build ./build
|
||||
# Setup server
|
||||
FROM base AS server
|
||||
|
||||
RUN pnpm i express@4
|
||||
|
||||
# Finalize
|
||||
FROM base
|
||||
|
||||
COPY http_server.js /var/www/stremio-web
|
||||
COPY --from=server /var/www/stremio-web/node_modules /var/www/stremio-web/node_modules
|
||||
COPY --from=app /var/www/stremio-web/build /var/www/stremio-web/build
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["node", "http_server.js"]
|
||||
|
|
|
|||
|
|
@ -41,15 +41,15 @@ docker run -p 8080:8080 stremio-web
|
|||
|
||||
### Board
|
||||
|
||||

|
||||

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

|
||||

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

|
||||

|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 652 KiB After Width: | Height: | Size: 652 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
BIN
assets/images/icon_196x196.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
assets/images/icon_512x512.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
BIN
assets/images/maskable_icon_196x196.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
assets/images/maskable_icon_512x512.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 159 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
59
manifest.json
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
{
|
||||
"name": "Stremio Web",
|
||||
"short_name": "Stremio",
|
||||
"description": "Freedom To Stream",
|
||||
"background_color": "#161523",
|
||||
"theme_color": "#2a2843",
|
||||
"orientation": "any",
|
||||
"display": "standalone",
|
||||
"display_override": ["standalone"],
|
||||
"scope": "./",
|
||||
"start_url": "./",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicons/icon_256x256.ico",
|
||||
"sizes": "256x256",
|
||||
"type": "image/vnd.microsoft.icon"
|
||||
},
|
||||
{
|
||||
"src": "images/maskable_icon_512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "images/maskable_icon_196x196.png",
|
||||
"sizes": "196x196",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "images/icon_512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "images/icon_196x196.png",
|
||||
"sizes": "196x196",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "screenshots/board_wide.webp",
|
||||
"sizes": "1440x900",
|
||||
"type": "image/webp",
|
||||
"form_factor": "wide",
|
||||
"label": "Homescreen of Stremio"
|
||||
},
|
||||
{
|
||||
"src": "screenshots/board_narrow.webp",
|
||||
"sizes": "414x896",
|
||||
"type": "image/webp",
|
||||
"form_factor": "narrow",
|
||||
"label": "Homescreen of Stremio"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "stremio",
|
||||
"displayName": "Stremio",
|
||||
"version": "5.0.0-beta.27",
|
||||
"version": "5.0.0-beta.29",
|
||||
"author": "Smart Code OOD",
|
||||
"private": true,
|
||||
"license": "gpl-2.0",
|
||||
|
|
@ -17,21 +17,21 @@
|
|||
"@babel/runtime": "7.26.0",
|
||||
"@sentry/browser": "8.42.0",
|
||||
"@stremio/stremio-colors": "5.2.0",
|
||||
"@stremio/stremio-core-web": "0.49.4",
|
||||
"@stremio/stremio-icons": "5.7.1",
|
||||
"@stremio/stremio-video": "0.0.62",
|
||||
"@stremio/stremio-core-web": "0.52.0",
|
||||
"@stremio/stremio-icons": "5.8.0",
|
||||
"@stremio/stremio-video": "0.0.70",
|
||||
"a-color-picker": "1.2.1",
|
||||
"bowser": "2.11.0",
|
||||
"buffer": "6.0.3",
|
||||
"classnames": "2.5.1",
|
||||
"eventemitter3": "5.0.1",
|
||||
"fast-equals": "^6.0.0",
|
||||
"filter-invalid-dom-props": "3.0.1",
|
||||
"hat": "^0.0.3",
|
||||
"i18next": "^24.0.5",
|
||||
"langs": "github:Stremio/nodejs-langs",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"lodash.intersection": "4.4.0",
|
||||
"lodash.isequal": "4.5.0",
|
||||
"lodash.throttle": "4.1.1",
|
||||
"magnet-uri": "6.2.0",
|
||||
"prop-types": "15.8.1",
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
"react-i18next": "^15.1.3",
|
||||
"react-is": "18.3.1",
|
||||
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
|
||||
"stremio-translations": "github:Stremio/stremio-translations#abe7684165a031755e9aee39da26daa806ba7824",
|
||||
"stremio-translations": "github:Stremio/stremio-translations#7c0c337f32163aa13158bb90cd6133da43feafef",
|
||||
"url": "0.11.4",
|
||||
"use-long-press": "^3.2.0"
|
||||
},
|
||||
|
|
@ -53,12 +53,10 @@
|
|||
"@stylistic/eslint-plugin": "^5.4.0",
|
||||
"@stylistic/eslint-plugin-jsx": "^4.4.1",
|
||||
"@types/hat": "^0.0.4",
|
||||
"@types/lodash.isequal": "^4.5.8",
|
||||
"@types/lodash.throttle": "^4.1.9",
|
||||
"@types/react": "^18.3.13",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"babel-loader": "9.2.1",
|
||||
"clean-webpack-plugin": "4.0.0",
|
||||
"copy-webpack-plugin": "12.0.2",
|
||||
"css-loader": "6.11.0",
|
||||
"cssnano": "7.0.6",
|
||||
|
|
@ -82,7 +80,6 @@
|
|||
"webpack": "5.97.0",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-dev-server": "^5.1.0",
|
||||
"webpack-pwa-manifest": "^4.3.0",
|
||||
"workbox-webpack-plugin": "^7.3.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1040
pnpm-lock.yaml
|
|
@ -6,11 +6,12 @@ const { useTranslation } = require('react-i18next');
|
|||
const { Router } = require('stremio-router');
|
||||
const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services');
|
||||
const { NotFound } = require('stremio/routes');
|
||||
const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, CONSTANTS, withCoreSuspender, useShell } = require('stremio/common');
|
||||
const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, ShortcutsProvider, CONSTANTS, withCoreSuspender, useShell, useBinaryState } = require('stremio/common');
|
||||
const ServicesToaster = require('./ServicesToaster');
|
||||
const DeepLinkHandler = require('./DeepLinkHandler');
|
||||
const SearchParamsHandler = require('./SearchParamsHandler');
|
||||
const { default: UpdaterBanner } = require('./UpdaterBanner');
|
||||
const { default: ShortcutsModal } = require('./ShortcutsModal');
|
||||
const ErrorDialog = require('./ErrorDialog');
|
||||
const withProtectedRoutes = require('./withProtectedRoutes');
|
||||
const routerViewsConfig = require('./routerViewsConfig');
|
||||
|
|
@ -38,6 +39,14 @@ const App = () => {
|
|||
};
|
||||
}, []);
|
||||
const [initialized, setInitialized] = React.useState(false);
|
||||
const [shortcutModalOpen,, closeShortcutsModal, toggleShortcutModal] = useBinaryState(false);
|
||||
|
||||
const onShortcut = React.useCallback((name) => {
|
||||
if (name === 'shortcuts') {
|
||||
toggleShortcutModal();
|
||||
}
|
||||
}, [toggleShortcutModal]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let prevPath = window.location.hash.slice(1);
|
||||
const onLocationHashChange = () => {
|
||||
|
|
@ -159,7 +168,8 @@ const App = () => {
|
|||
services.core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'PullUserFromAPI'
|
||||
action: 'PullUserFromAPI',
|
||||
args: {}
|
||||
}
|
||||
});
|
||||
services.core.transport.dispatch({
|
||||
|
|
@ -203,6 +213,10 @@ const App = () => {
|
|||
<ToastProvider className={styles['toasts-container']}>
|
||||
<TooltipProvider className={styles['tooltip-container']}>
|
||||
<FileDropProvider className={styles['file-drop-container']}>
|
||||
<ShortcutsProvider onShortcut={onShortcut}>
|
||||
{
|
||||
shortcutModalOpen && <ShortcutsModal onClose={closeShortcutsModal}/>
|
||||
}
|
||||
<ServicesToaster />
|
||||
<DeepLinkHandler />
|
||||
<SearchParamsHandler />
|
||||
|
|
@ -212,6 +226,7 @@ const App = () => {
|
|||
viewsConfig={routerViewsConfig}
|
||||
onPathNotMatch={onPathNotMatch}
|
||||
/>
|
||||
</ShortcutsProvider>
|
||||
</FileDropProvider>
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const ErrorDialog = ({ className }) => {
|
|||
<div className={classnames(className, styles['error-container'])}>
|
||||
<Image
|
||||
className={styles['error-image']}
|
||||
src={require('/images/empty.png')}
|
||||
src={require('/assets/images/empty.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
<div className={styles['error-message']}>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const isEqual = require('lodash.isequal');
|
||||
const { deepEqual } = require('fast-equals');
|
||||
const { withCoreSuspender, useProfile, useToast } = require('stremio/common');
|
||||
const { useServices } = require('stremio/services');
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ const SearchParamsHandler = () => {
|
|||
|
||||
setSearchParams((previousSearchParams) => {
|
||||
const currentSearchParams = Object.fromEntries(searchParams.entries());
|
||||
return isEqual(previousSearchParams, currentSearchParams) ? previousSearchParams : currentSearchParams;
|
||||
return deepEqual(previousSearchParams, currentSearchParams) ? previousSearchParams : currentSearchParams;
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
|||
59
src/App/ShortcutsModal/ShortcutsModal.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Icon from '@stremio/stremio-icons/react';
|
||||
import { useShortcuts } from 'stremio/common';
|
||||
import { Button, ShortcutsGroup } from 'stremio/components';
|
||||
import styles from './styles.less';
|
||||
|
||||
type Props = {
|
||||
onClose: () => void,
|
||||
};
|
||||
|
||||
const ShortcutsModal = ({ onClose }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { grouped } = useShortcuts();
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = ({ key }: KeyboardEvent) => {
|
||||
key === 'Escape' && onClose();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
|
||||
return createPortal((
|
||||
<div className={styles['shortcuts-modal']}>
|
||||
<div className={styles['backdrop']} onClick={onClose} />
|
||||
|
||||
<div className={styles['container']}>
|
||||
<div className={styles['header']}>
|
||||
<div className={styles['title']}>
|
||||
{t('SETTINGS_NAV_SHORTCUTS')}
|
||||
</div>
|
||||
|
||||
<Button className={styles['close-button']} title={t('BUTTON_CLOSE')} onClick={onClose}>
|
||||
<Icon className={styles['icon']} name={'close'} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles['content']}>
|
||||
{
|
||||
grouped.map(({ name, label, shortcuts }) => (
|
||||
<ShortcutsGroup
|
||||
key={name}
|
||||
label={label}
|
||||
shortcuts={shortcuts}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
), document.body);
|
||||
};
|
||||
|
||||
export default ShortcutsModal;
|
||||
2
src/App/ShortcutsModal/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import ShortcutsModal from './ShortcutsModal';
|
||||
export default ShortcutsModal;
|
||||
91
src/App/ShortcutsModal/styles.less
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
|
||||
|
||||
.shortcuts-modal {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: @color-background-dark5-40;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-height: 80%;
|
||||
max-width: 80%;
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--modal-background-color);
|
||||
box-shadow: var(--outer-glow);
|
||||
overflow-y: auto;
|
||||
|
||||
.header {
|
||||
flex: none;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 5rem;
|
||||
padding-left: 2.5rem;
|
||||
padding-right: 1rem;
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: relative;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
z-index: 2;
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--primary-foreground-color);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
.icon {
|
||||
opacity: 1;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--primary-foreground-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 3rem;
|
||||
padding: 0 2.5rem;
|
||||
padding-bottom: 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
@font-face {
|
||||
font-family: 'PlusJakartaSans';
|
||||
src: url('/fonts/PlusJakartaSans.ttf') format('truetype');
|
||||
src: url('/assets/fonts/PlusJakartaSans.ttf') format('truetype');
|
||||
}
|
||||
|
||||
:global {
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
@top-overlay-size: 5.25rem;
|
||||
@bottom-overlay-size: 0rem;
|
||||
@overlap-size: 3rem;
|
||||
@transparency-grandient-pad: 6rem;
|
||||
@transparency-gradient-pad: 6rem;
|
||||
|
||||
:root {
|
||||
--landscape-shape-ratio: 0.5625;
|
||||
|
|
@ -48,7 +48,7 @@
|
|||
--color-x: #000000;
|
||||
--color-reddit: #FF4500;
|
||||
--color-imdb: #f5c518;
|
||||
--color-trakt: #ED2224;
|
||||
--color-trakt: rgb(255, 255, 255);
|
||||
--color-placeholder: #60606080;
|
||||
--color-placeholder-text: @color-surface-50;
|
||||
--color-placeholder-background: @color-surface-dark5-20;
|
||||
|
|
@ -69,7 +69,7 @@
|
|||
--top-overlay-size: @top-overlay-size;
|
||||
--bottom-overlay-size: @bottom-overlay-size;
|
||||
--overlap-size: @overlap-size;
|
||||
--transparency-grandient-pad: @transparency-grandient-pad;
|
||||
--transparency-gradient-pad: @transparency-gradient-pad;
|
||||
--safe-area-inset-top: @safe-area-inset-top;
|
||||
--safe-area-inset-right: @safe-area-inset-right;
|
||||
--safe-area-inset-bottom: @safe-area-inset-bottom;
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const FileDropProvider = ({ className, children }: Props) => {
|
|||
.then((buffer) => {
|
||||
listeners
|
||||
.filter(([type]) => file.type ? type === file.type : isFileType(buffer, type))
|
||||
.forEach(([, listerner]) => listerner(file.name, buffer));
|
||||
.forEach(([, listener]) => listener(file.name, buffer));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
67
src/common/Shortcuts/Shortcuts.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import React, { createContext, useCallback, useContext, useEffect, useRef } from 'react';
|
||||
import shortcuts from './shortcuts.json';
|
||||
|
||||
const SHORTCUTS = shortcuts.map(({ shortcuts }) => shortcuts).flat();
|
||||
|
||||
export type ShortcutName = string;
|
||||
export type ShortcutListener = (combo: number) => void;
|
||||
|
||||
interface ShortcutsContext {
|
||||
grouped: ShortcutGroup[],
|
||||
on: (name: ShortcutName, listener: ShortcutListener) => void,
|
||||
off: (name: ShortcutName, listener: ShortcutListener) => void,
|
||||
}
|
||||
|
||||
const ShortcutsContext = createContext<ShortcutsContext>({} as ShortcutsContext);
|
||||
|
||||
type Props = {
|
||||
children: JSX.Element,
|
||||
onShortcut: (name: ShortcutName) => void,
|
||||
};
|
||||
|
||||
const ShortcutsProvider = ({ children, onShortcut }: Props) => {
|
||||
const listeners = useRef<Map<ShortcutName, Set<ShortcutListener>>>(new Map());
|
||||
|
||||
const onKeyDown = useCallback(({ ctrlKey, shiftKey, code, key }: KeyboardEvent) => {
|
||||
SHORTCUTS.forEach(({ name, combos }) => combos.forEach((keys) => {
|
||||
const modifers = (keys.includes('Ctrl') ? ctrlKey : true)
|
||||
&& (keys.includes('Shift') ? shiftKey : true);
|
||||
|
||||
if (modifers && (keys.includes(code) || keys.includes(key.toUpperCase()))) {
|
||||
const combo = combos.indexOf(keys);
|
||||
listeners.current.get(name)?.forEach((listener) => listener(combo));
|
||||
|
||||
onShortcut(name as ShortcutName);
|
||||
}
|
||||
}));
|
||||
}, [onShortcut]);
|
||||
|
||||
const on = (name: ShortcutName, listener: ShortcutListener) => {
|
||||
!listeners.current.has(name) && listeners.current.set(name, new Set());
|
||||
listeners.current.get(name)!.add(listener);
|
||||
};
|
||||
|
||||
const off = (name: ShortcutName, listener: ShortcutListener) => {
|
||||
listeners.current.get(name)?.delete(listener);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [onKeyDown]);
|
||||
|
||||
return (
|
||||
<ShortcutsContext.Provider value={{ grouped: shortcuts, on, off }}>
|
||||
{children}
|
||||
</ShortcutsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const useShortcuts = () => {
|
||||
return useContext(ShortcutsContext);
|
||||
};
|
||||
|
||||
export {
|
||||
ShortcutsProvider,
|
||||
useShortcuts,
|
||||
};
|
||||
8
src/common/Shortcuts/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { ShortcutsProvider, useShortcuts } from './Shortcuts';
|
||||
import onShortcut from './onShortcut';
|
||||
|
||||
export {
|
||||
ShortcutsProvider,
|
||||
useShortcuts,
|
||||
onShortcut,
|
||||
};
|
||||
15
src/common/Shortcuts/onShortcut.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { DependencyList, useCallback, useEffect } from 'react';
|
||||
import { ShortcutListener, ShortcutName, useShortcuts } from './Shortcuts';
|
||||
|
||||
const onShortcut = (name: ShortcutName, listener: ShortcutListener, deps: DependencyList) => {
|
||||
const shortcuts = useShortcuts();
|
||||
|
||||
const listenerCallback = useCallback(listener, deps);
|
||||
|
||||
useEffect(() => {
|
||||
shortcuts.on(name, listenerCallback);
|
||||
return () => shortcuts.off(name, listenerCallback);
|
||||
}, [listenerCallback]);
|
||||
};
|
||||
|
||||
export default onShortcut;
|
||||
104
src/common/Shortcuts/shortcuts.json
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
[
|
||||
{
|
||||
"name": "general",
|
||||
"label": "SETTINGS_NAV_GENERAL",
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "navigateTabs",
|
||||
"label": "SETTINGS_SHORTCUT_NAVIGATE_MENUS",
|
||||
"combos": [["1", "2", "3", "4", "5", "6"]]
|
||||
},
|
||||
{
|
||||
"name": "navigateSearch",
|
||||
"label": "SETTINGS_SHORTCUT_GO_TO_SEARCH",
|
||||
"combos": [["0"]]
|
||||
},
|
||||
{
|
||||
"name": "fullscreen",
|
||||
"label": "SETTINGS_SHORTCUT_FULLSCREEN",
|
||||
"combos": [["F"]]
|
||||
},
|
||||
{
|
||||
"name": "exit",
|
||||
"label": "SETTINGS_SHORTCUT_EXIT_BACK",
|
||||
"combos": [["Escape"]]
|
||||
},
|
||||
{
|
||||
"name": "shortcuts",
|
||||
"label": "SETTINGS_SHORTCUT_SHORTCUTS",
|
||||
"combos": [["Ctrl", "/"]]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "player",
|
||||
"label": "SETTINGS_NAV_PLAYER",
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "playPause",
|
||||
"label": "SETTINGS_SHORTCUT_PLAY_PAUSE",
|
||||
"combos": [["Space"]]
|
||||
},
|
||||
{
|
||||
"name": "seekForward",
|
||||
"label": "SETTINGS_SHORTCUT_SEEK_FORWARD",
|
||||
"combos": [["ArrowRight"], ["Shift", "ArrowRight"]]
|
||||
},
|
||||
{
|
||||
"name": "seekBackward",
|
||||
"label": "SETTINGS_SHORTCUT_SEEK_BACKWARD",
|
||||
"combos": [["ArrowLeft"], ["Shift", "ArrowLeft"]]
|
||||
},
|
||||
{
|
||||
"name": "volumeUp",
|
||||
"label": "SETTINGS_SHORTCUT_VOLUME_UP",
|
||||
"combos": [["ArrowUp"]]
|
||||
},
|
||||
{
|
||||
"name": "volumeDown",
|
||||
"label": "SETTINGS_SHORTCUT_VOLUME_DOWN",
|
||||
"combos": [["ArrowDown"]]
|
||||
},
|
||||
{
|
||||
"name": "mute",
|
||||
"label": "SETTINGS_SHORTCUT_MUTE",
|
||||
"combos": [["M"]]
|
||||
},
|
||||
{
|
||||
"name": "subtitlesSize",
|
||||
"label": "SETTINGS_SHORTCUT_SUBTITLES_SIZE",
|
||||
"combos": [["-"], ["="]]
|
||||
},
|
||||
{
|
||||
"name": "subtitlesDelay",
|
||||
"label": "SETTINGS_SHORTCUT_SUBTITLES_DELAY",
|
||||
"combos": [["G"], ["H"]]
|
||||
},
|
||||
{
|
||||
"name": "subtitlesMenu",
|
||||
"label": "SETTINGS_SHORTCUT_MENU_SUBTITLES",
|
||||
"combos": [["S"]]
|
||||
},
|
||||
{
|
||||
"name": "audioMenu",
|
||||
"label": "SETTINGS_SHORTCUT_MENU_AUDIO",
|
||||
"combos": [["A"]]
|
||||
},
|
||||
{
|
||||
"name": "infoMenu",
|
||||
"label": "SETTINGS_SHORTCUT_MENU_INFO",
|
||||
"combos": [["I"]]
|
||||
},
|
||||
{
|
||||
"name": "speedMenu",
|
||||
"label": "SETTINGS_SHORTCUT_MENU_PLAYBACK_SPEED",
|
||||
"combos": [["R"]]
|
||||
},
|
||||
{
|
||||
"name": "statisticsMenu",
|
||||
"label": "SETTINGS_SHORTCUT_MENU_STATISTICS",
|
||||
"combos": [["D"]]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
11
src/common/Shortcuts/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
type Shortcut = {
|
||||
name: string,
|
||||
label: string,
|
||||
combos: string[][],
|
||||
};
|
||||
|
||||
type ShortcutGroup = {
|
||||
name: string,
|
||||
label: string,
|
||||
shortcuts: Shortcut[],
|
||||
};
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
&.error {
|
||||
.icon-container {
|
||||
.icon {
|
||||
color: var(--color-trakt);
|
||||
color: var(--danger-accent-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const { FileDropProvider, onFileDrop } = require('./FileDrop');
|
|||
const { PlatformProvider, usePlatform } = require('./Platform');
|
||||
const { ToastProvider, useToast } = require('./Toast');
|
||||
const { TooltipProvider, Tooltip } = require('./Tooltips');
|
||||
const { ShortcutsProvider, useShortcuts, onShortcut } = require('./Shortcuts');
|
||||
const comparatorWithPriorities = require('./comparatorWithPriorities');
|
||||
const CONSTANTS = require('./CONSTANTS');
|
||||
const { withCoreSuspender, useCoreSuspender } = require('./CoreSuspender');
|
||||
|
|
@ -35,6 +36,9 @@ module.exports = {
|
|||
onFileDrop,
|
||||
PlatformProvider,
|
||||
usePlatform,
|
||||
ShortcutsProvider,
|
||||
useShortcuts,
|
||||
onShortcut,
|
||||
ToastProvider,
|
||||
useToast,
|
||||
TooltipProvider,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
"name": "العربية",
|
||||
"codes": ["ar-AR", "ara"]
|
||||
},
|
||||
{
|
||||
"name": "Беларуская",
|
||||
"codes": ["be-BY", "bel"]
|
||||
},
|
||||
{
|
||||
"name": "български език",
|
||||
"codes": ["bg-BG", "bul"]
|
||||
|
|
@ -13,7 +17,7 @@
|
|||
},
|
||||
{
|
||||
"name": "català",
|
||||
"codes": ["ca-CA", "cat"]
|
||||
"codes": ["ca-ES", "cat"]
|
||||
},
|
||||
{
|
||||
"name": "čeština",
|
||||
|
|
@ -43,6 +47,10 @@
|
|||
"name": "español",
|
||||
"codes": ["es-ES", "spa"]
|
||||
},
|
||||
{
|
||||
"name": "Eesti",
|
||||
"codes": ["et-EE", "est"]
|
||||
},
|
||||
{
|
||||
"name": "euskara",
|
||||
"codes": ["eu-ES", "eus"]
|
||||
|
|
@ -51,6 +59,10 @@
|
|||
"name": "فارسی",
|
||||
"codes": ["fa-IR", "fas"]
|
||||
},
|
||||
{
|
||||
"name": "Suomi",
|
||||
"codes": ["fi-FI", "fin"]
|
||||
},
|
||||
{
|
||||
"name": "Français",
|
||||
"codes": ["fr-FR", "fre"]
|
||||
|
|
@ -107,6 +119,10 @@
|
|||
"name": "Norsk nynorsk",
|
||||
"codes": ["nn-NO", "nno"]
|
||||
},
|
||||
{
|
||||
"name": "ਪੰਜਾਬੀ",
|
||||
"codes": ["pa-IN", "pan"]
|
||||
},
|
||||
{
|
||||
"name": "język polski",
|
||||
"codes": ["pl-PL", "pol"]
|
||||
|
|
@ -119,13 +135,17 @@
|
|||
"name": "português",
|
||||
"codes": ["pt-PT", "por"]
|
||||
},
|
||||
{
|
||||
"name": "Română",
|
||||
"codes": ["ro-RO", "ron"]
|
||||
},
|
||||
{
|
||||
"name": "русский язык",
|
||||
"codes": ["ru-RU", "rus"]
|
||||
},
|
||||
{
|
||||
"name": "Svenska",
|
||||
"codes": ["sv-SE", "swe"]
|
||||
"name": "Slovenčina",
|
||||
"codes": ["sk-SK", "slk"]
|
||||
},
|
||||
{
|
||||
"name": "slovenski jezik",
|
||||
|
|
@ -135,10 +155,18 @@
|
|||
"name": "српски језик",
|
||||
"codes": ["sr-RS", "srp"]
|
||||
},
|
||||
{
|
||||
"name": "Svenska",
|
||||
"codes": ["sv-SE", "swe"]
|
||||
},
|
||||
{
|
||||
"name": "తెలుగు",
|
||||
"codes": ["te-IN", "tel"]
|
||||
},
|
||||
{
|
||||
"name": "தமிழ்",
|
||||
"codes": ["tl-TM", "tam"]
|
||||
},
|
||||
{
|
||||
"name": "Türkçe",
|
||||
"codes": ["tr-TR", "tur"]
|
||||
|
|
@ -147,6 +175,10 @@
|
|||
"name": "українська мова",
|
||||
"codes": ["uk-UA", "ukr"]
|
||||
},
|
||||
{
|
||||
"name": "اُرْدُو",
|
||||
"codes": ["ur-PK", "urd"]
|
||||
},
|
||||
{
|
||||
"name": "Tiếng Việt",
|
||||
"codes": ["vi-VN", "vie"]
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
const React = require('react');
|
||||
const throttle = require('lodash.throttle');
|
||||
const isEqual = require('lodash.isequal');
|
||||
const { deepEqual } = require('fast-equals');
|
||||
const intersection = require('lodash.intersection');
|
||||
const { useCoreSuspender } = require('stremio/common/CoreSuspender');
|
||||
const { useRouteFocused } = require('stremio-router');
|
||||
|
|
@ -19,7 +19,7 @@ const useModelState = ({ action, ...args }) => {
|
|||
const [state, setState] = React.useReducer(
|
||||
(prevState, nextState) => {
|
||||
return Object.keys(prevState).reduce((result, key) => {
|
||||
result[key] = isEqual(prevState[key], nextState[key]) ? prevState[key] : nextState[key];
|
||||
result[key] = deepEqual(prevState[key], nextState[key]) ? prevState[key] : nextState[key];
|
||||
return result;
|
||||
}, {});
|
||||
},
|
||||
|
|
|
|||
4
src/common/useNotifications.d.ts
vendored
|
|
@ -1,2 +1,2 @@
|
|||
declare const useNotifcations: () => Notifications;
|
||||
export = useNotifcations;
|
||||
declare const useNotifications: () => Notifications;
|
||||
export = useNotifications;
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: @small) and (orientation: portait) {
|
||||
@media only screen and (min-width: @small) and (orientation: portrait) {
|
||||
.bottom-sheet {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@
|
|||
}
|
||||
|
||||
&.error {
|
||||
border-color: var(--color-trakt);
|
||||
border-color: var(--danger-accent-color);
|
||||
}
|
||||
|
||||
&.checked {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const ColorPicker = ({ className, value, onInput }) => {
|
|||
showRGB: false,
|
||||
showAlpha: true
|
||||
});
|
||||
const pickerClipboard = pickerElementRef.current.querySelector('.a-color-picker-clipbaord');
|
||||
const pickerClipboard = pickerElementRef.current.querySelector('.a-color-picker-clipboard');
|
||||
if (pickerClipboard instanceof HTMLElement) {
|
||||
pickerClipboard.tabIndex = -1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
box-shadow: 0 0 .2rem var(--color-surfacedark);
|
||||
}
|
||||
|
||||
:global(.a-color-picker-clipbaord) {
|
||||
:global(.a-color-picker-clipboard) {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const ContineWatchingItem = require('./ContinueWatchingItem');
|
||||
const ContinueWatchingItem = require('./ContinueWatchingItem');
|
||||
|
||||
module.exports = ContineWatchingItem;
|
||||
module.exports = ContinueWatchingItem;
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const { Button } = require('stremio/components');
|
||||
const useTranslate = require('stremio/common/useTranslate');
|
||||
const styles = require('./styles');
|
||||
|
||||
const MetaLinks = ({ className, label, links }) => {
|
||||
const { t } = useTranslation();
|
||||
const { string, stringWithPrefix } = useTranslate();
|
||||
return (
|
||||
<div className={classnames(className, styles['meta-links-container'])}>
|
||||
{
|
||||
typeof label === 'string' && label.length > 0 ?
|
||||
<div className={styles['label-container']}>
|
||||
{t(`LINKS_${label.toUpperCase()}`)}
|
||||
{ stringWithPrefix(label.toUpperCase(), 'LINKS') }
|
||||
</div>
|
||||
:
|
||||
null
|
||||
|
|
@ -24,7 +24,7 @@ const MetaLinks = ({ className, label, links }) => {
|
|||
<div className={styles['links-container']}>
|
||||
{links.map(({ label, href }, index) => (
|
||||
<Button key={index} className={styles['link-container']} title={label} href={href}>
|
||||
{ t(label) }
|
||||
{ string(label) }
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
border-radius: 2rem;
|
||||
height: @height;
|
||||
width: fit-content;
|
||||
backdrop-filter: blur(5px);
|
||||
|
||||
.icon-container {
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const HorizontalNavBar = React.memo(({ className, route, query, title, backButto
|
|||
<div className={styles['logo-container']}>
|
||||
<Image
|
||||
className={styles['logo']}
|
||||
src={require('/images/stremio_symbol.png')}
|
||||
src={require('/assets/images/stremio_symbol.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -52,12 +52,12 @@ const NavMenuContent = ({ onClick }) => {
|
|||
className={styles['avatar-container']}
|
||||
style={{
|
||||
backgroundImage: profile.auth === null ?
|
||||
`url('${require('/images/anonymous.png')}')`
|
||||
`url('${require('/assets/images/anonymous.png')}')`
|
||||
:
|
||||
profile.auth.user.avatar ?
|
||||
`url('${profile.auth.user.avatar}')`
|
||||
:
|
||||
`url('${require('/images/default_avatar.png')}')`
|
||||
`url('${require('/assets/images/default_avatar.png')}')`
|
||||
}}
|
||||
/>
|
||||
<div className={styles['user-info-details']}>
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@
|
|||
}
|
||||
|
||||
&.error {
|
||||
border-color: var(--color-trakt);
|
||||
border-color: var(--danger-accent-color);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
|
|
|
|||
22
src/components/ShortcutsGroup/Combos/Combos.less
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
.combos {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: visible;
|
||||
|
||||
.combo {
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: visible;
|
||||
|
||||
.separator {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
font-size: 1rem;
|
||||
color: var(--primary-foreground-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/components/ShortcutsGroup/Combos/Combos.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Keys from './Keys';
|
||||
import styles from './Combos.less';
|
||||
|
||||
type Props = {
|
||||
combos: string[][],
|
||||
};
|
||||
|
||||
const Combos = ({ combos }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={styles['combos']}>
|
||||
{
|
||||
combos.map((keys, index) => (
|
||||
<div className={styles['combo']} key={index}>
|
||||
<Keys keys={keys} />
|
||||
{
|
||||
index < (combos.length - 1) && (
|
||||
<div className={styles['separator']}>
|
||||
{ t('SETTINGS_SHORTCUT_OR') }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Combos;
|
||||
26
src/components/ShortcutsGroup/Combos/Keys/Keys.less
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
kbd {
|
||||
flex: none;
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 2.5rem;
|
||||
min-width: 2.5rem;
|
||||
padding: 0 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--primary-foreground-color);
|
||||
border-radius: 0.25em;
|
||||
box-shadow: 0 4px 0 1px rgba(255, 255, 255, 0.1);
|
||||
background-color: var(--overlay-color);
|
||||
}
|
||||
|
||||
.separator {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
font-size: 1rem;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
51
src/components/ShortcutsGroup/Combos/Keys/Keys.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import React, { Fragment, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styles from './Keys.less';
|
||||
|
||||
type Props = {
|
||||
keys: string[],
|
||||
};
|
||||
|
||||
const Keys = ({ keys }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const keyLabelMap: Record<string, string> = useMemo(() => ({
|
||||
'Shift': `⇧ ${t('SETTINGS_SHORTCUT_SHIFT')}`,
|
||||
'Space': t('SETTINGS_SHORTCUT_SPACE'),
|
||||
'Ctrl': t('SETTINGS_SHORTCUT_CTRL'),
|
||||
'Escape': t('SETTINGS_SHORTCUT_ESC'),
|
||||
'ArrowUp': '↑',
|
||||
'ArrowDown': '↓',
|
||||
'ArrowLeft': '←',
|
||||
'ArrowRight': '→',
|
||||
}), [t]);
|
||||
|
||||
const isRange = useMemo(() => {
|
||||
return keys.length > 1 && keys.every((key) => !Number.isNaN(parseInt(key)));
|
||||
}, [keys]);
|
||||
|
||||
const filteredKeys = useMemo(() => {
|
||||
return isRange ? [keys[0], keys[keys.length - 1]] : keys;
|
||||
}, [keys, isRange]);
|
||||
|
||||
return (
|
||||
filteredKeys.map((key, index) => (
|
||||
<Fragment key={key}>
|
||||
<kbd>
|
||||
{keyLabelMap[key] ?? key.toUpperCase()}
|
||||
</kbd>
|
||||
{
|
||||
index < (filteredKeys.length - 1) && (
|
||||
<div className={styles['separator']}>
|
||||
{
|
||||
isRange ? t('SETTINGS_SHORTCUT_TO') : '+'
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Fragment>
|
||||
))
|
||||
);
|
||||
};
|
||||
|
||||
export default Keys;
|
||||
2
src/components/ShortcutsGroup/Combos/Keys/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import Keys from './Keys';
|
||||
export default Keys;
|
||||
2
src/components/ShortcutsGroup/Combos/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import Combos from './Combos';
|
||||
export default Combos;
|
||||
44
src/components/ShortcutsGroup/ShortcutsGroup.less
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
.shortcuts-group {
|
||||
flex: 1 1 0;
|
||||
position: relative;
|
||||
width: 30rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
overflow: visible;
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
display: flex;
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: var(--primary-foreground-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.shortcuts {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
overflow: visible;
|
||||
|
||||
.shortcut {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
overflow: visible;
|
||||
|
||||
.label {
|
||||
position: relative;
|
||||
font-size: 1rem;
|
||||
color: var(--primary-foreground-color);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/components/ShortcutsGroup/ShortcutsGroup.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Combos from './Combos';
|
||||
import styles from './ShortcutsGroup.less';
|
||||
|
||||
type Props = {
|
||||
className?: string,
|
||||
label: string,
|
||||
shortcuts: Shortcut[],
|
||||
};
|
||||
|
||||
const ShortcutsGroup = ({ className, label, shortcuts }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={classNames(className, styles['shortcuts-group'])}>
|
||||
<div className={styles['title']}>
|
||||
{t(label)}
|
||||
</div>
|
||||
|
||||
<div className={styles['shortcuts']}>
|
||||
{
|
||||
shortcuts.map(({ name, label, combos }) => (
|
||||
<div className={styles['shortcut']} key={name}>
|
||||
<div className={styles['label']}>
|
||||
{t(label)}
|
||||
</div>
|
||||
<Combos combos={combos} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShortcutsGroup;
|
||||
2
src/components/ShortcutsGroup/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import ShortcutsGroup from './ShortcutsGroup';
|
||||
export default ShortcutsGroup;
|
||||
|
|
@ -142,11 +142,11 @@ const Slider = ({ className, value, buffered, minimumValue, maximumValue, disabl
|
|||
<div className={styles['layer']}>
|
||||
<div
|
||||
className={classnames(styles['track-after'], { [styles['audio-boost']]: audioBoost })}
|
||||
style={{ '--mask-width': `calc(${thumbPosition} * 100%)` }}
|
||||
style={{ '--mask-width': `calc(${thumbPosition.toFixed(3)} * 100%)` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles['layer']}>
|
||||
<div className={styles['thumb']} style={{ marginLeft: `calc(100% * ${thumbPosition})` }} />
|
||||
<div className={styles['thumb']} style={{ marginLeft: `calc(100% * ${thumbPosition.toFixed(3)})` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,11 +12,12 @@ const useProfile = require('stremio/common/useProfile');
|
|||
const VideoPlaceholder = require('./VideoPlaceholder');
|
||||
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 profile = useProfile();
|
||||
const { t } = useTranslation();
|
||||
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
|
||||
|
||||
const popupLabelOnMouseUp = React.useCallback((event) => {
|
||||
if (!event.nativeEvent.togglePopupPrevented) {
|
||||
if (event.nativeEvent.ctrlKey || event.nativeEvent.button === 2) {
|
||||
|
|
@ -68,27 +69,23 @@ const Video = React.forwardRef(({ className, id, title, thumbnail, season, episo
|
|||
}
|
||||
}
|
||||
}, [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 handleRef = React.useCallback((node) => {
|
||||
if (popupRef) {
|
||||
if (typeof popupRef === 'function') {
|
||||
popupRef(node);
|
||||
} else {
|
||||
popupRef.current = node;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selected && ref.current) {
|
||||
if ((progress && watched) || !watched) {
|
||||
ref.current.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'start'
|
||||
});
|
||||
}
|
||||
}
|
||||
if (ref) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(node);
|
||||
} else {
|
||||
ref.current = node;
|
||||
}
|
||||
}
|
||||
}, [popupRef]);
|
||||
}, [selected]);
|
||||
|
||||
return (
|
||||
<Button {...props} className={classnames(className, styles['video-container'])} title={title} ref={handleRef}>
|
||||
<Button {...props} ref={ref} className={classnames(className, styles['video-container'], { [styles['selected']]: selected })} title={title}>
|
||||
{
|
||||
typeof thumbnail === 'string' && thumbnail.length > 0 ?
|
||||
<div className={styles['thumbnail-container']}>
|
||||
|
|
@ -159,7 +156,7 @@ const Video = React.forwardRef(({ className, id, title, thumbnail, season, episo
|
|||
{children}
|
||||
</Button>
|
||||
);
|
||||
}, []);
|
||||
}, [selected]);
|
||||
const renderMenu = React.useMemo(() => function renderMenu() {
|
||||
return (
|
||||
<div className={styles['context-menu-content']} onPointerDown={popupMenuOnPointerDown} onContextMenu={popupMenuOnContextMenu} onClick={popupMenuOnClick} onKeyDown={popupMenuOnKeyDown}>
|
||||
|
|
@ -203,7 +200,7 @@ const Video = React.forwardRef(({ className, id, title, thumbnail, season, episo
|
|||
renderMenu={renderMenu}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
Video.Placeholder = VideoPlaceholder;
|
||||
|
||||
|
|
@ -220,6 +217,7 @@ Video.propTypes = {
|
|||
progress: PropTypes.number,
|
||||
scheduled: PropTypes.bool,
|
||||
seasonWatched: PropTypes.bool,
|
||||
selected: PropTypes.bool,
|
||||
deepLinks: PropTypes.shape({
|
||||
metaDetailsStreams: PropTypes.string,
|
||||
player: PropTypes.string
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@
|
|||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
border: 0.15rem solid transparent;
|
||||
|
||||
@supports (scroll-margin: 1.25rem) {
|
||||
scroll-margin: 1.25rem;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
|
|
@ -172,6 +177,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
animation: border 3s ease-in-out forwards;
|
||||
}
|
||||
|
||||
@keyframes border {
|
||||
0% {
|
||||
border: 0.15rem solid var(--primary-accent-color);
|
||||
}
|
||||
100% {
|
||||
border: 0.15rem solid transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.context-menu-container {
|
||||
max-width: calc(90% - 1.5rem);
|
||||
z-index: 2;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import RadioButton from './RadioButton';
|
|||
import SearchBar from './SearchBar';
|
||||
import SharePrompt from './SharePrompt';
|
||||
import Slider from './Slider';
|
||||
import ShortcutsGroup from './ShortcutsGroup';
|
||||
import TextInput from './TextInput';
|
||||
import Toggle from './Toggle';
|
||||
import Transition from './Transition';
|
||||
|
|
@ -59,6 +60,7 @@ export {
|
|||
SearchBar,
|
||||
SharePrompt,
|
||||
Slider,
|
||||
ShortcutsGroup,
|
||||
TextInput,
|
||||
Toggle,
|
||||
Transition,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="Stremio">
|
||||
<link rel="icon" type="image/x-icon" href="<%= htmlWebpackPlugin.options.faviconsPath %>/favicon.ico">
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<title>Stremio - Freedom to Stream</title>
|
||||
<%= htmlWebpackPlugin.tags.headTags %>
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ const ReactIs = require('react-is');
|
|||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const UrlUtils = require('url');
|
||||
const isEqual = require('lodash.isequal');
|
||||
const { deepEqual } = require('fast-equals');
|
||||
const { RouteFocusedProvider } = require('../RouteFocusedContext');
|
||||
const Route = require('../Route');
|
||||
const routeConfigForPath = require('./routeConfigForPath');
|
||||
|
|
@ -54,11 +54,11 @@ const Router = ({ className, onPathNotMatch, onRouteChange, ...props }) => {
|
|||
return {
|
||||
key: `${routeViewIndex}${routeIndex}`,
|
||||
component: routeConfig.component,
|
||||
urlParams: view !== null && isEqual(view.urlParams, urlParams) ?
|
||||
urlParams: view !== null && deepEqual(view.urlParams, urlParams) ?
|
||||
view.urlParams
|
||||
:
|
||||
urlParams,
|
||||
queryParams: view !== null && isEqual(Array.from(view.queryParams.entries()), Array.from(queryParams.entries())) ?
|
||||
queryParams: view !== null && deepEqual(Array.from(view.queryParams.entries()), Array.from(queryParams.entries())) ?
|
||||
view.queryParams
|
||||
:
|
||||
queryParams
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const mapSelectableInputs = (installedAddons, remoteAddons, t) => {
|
|||
remoteAddons.selected !== null ?
|
||||
t.stringWithPrefix(remoteAddons.selected.request.path.type, 'TYPE_')
|
||||
:
|
||||
typeSelect.title;
|
||||
t.string('SELECT_TYPE');
|
||||
},
|
||||
onSelect: (value) => {
|
||||
window.location = value;
|
||||
|
|
|
|||
|
|
@ -22,11 +22,10 @@ const Board = () => {
|
|||
const profile = useProfile();
|
||||
const boardCatalogsOffset = continueWatchingPreview.items.length > 0 ? 1 : 0;
|
||||
const scrollContainerRef = React.useRef();
|
||||
const streamingServerWarningDismissed = React.useMemo(() => {
|
||||
return streamingServer.settings !== null && streamingServer.settings.type === 'Ready' || (
|
||||
!isNaN(profile.settings.streamingServerWarningDismissed.getTime()) &&
|
||||
profile.settings.streamingServerWarningDismissed.getTime() > Date.now()
|
||||
);
|
||||
const showStreamingServerWarning = React.useMemo(() => {
|
||||
return streamingServer.settings !== null && streamingServer.settings.type === 'Err' && (
|
||||
isNaN(profile.settings.streamingServerWarningDismissed.getTime()) ||
|
||||
profile.settings.streamingServerWarningDismissed.getTime() < Date.now());
|
||||
}, [profile.settings, streamingServer.settings]);
|
||||
const onVisibleRangeChange = React.useCallback(() => {
|
||||
const range = getVisibleChildrenRange(scrollContainerRef.current);
|
||||
|
|
@ -103,7 +102,7 @@ const Board = () => {
|
|||
</div>
|
||||
</MainNavBars>
|
||||
{
|
||||
!streamingServerWarningDismissed ?
|
||||
showStreamingServerWarning ?
|
||||
<StreamingServerWarning className={styles['board-warning-container']} />
|
||||
:
|
||||
null
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const Placeholder = () => {
|
|||
<div className={styles['image-container']}>
|
||||
<Image
|
||||
className={styles['image']}
|
||||
src={require('/images/calendar_placeholder.png')}
|
||||
src={require('/assets/images/calendar_placeholder.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,25 @@
|
|||
|
||||
@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 {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
|
@ -27,12 +46,9 @@
|
|||
}
|
||||
|
||||
.heading {
|
||||
flex: none;
|
||||
position: relative;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
align-items: flex-start;
|
||||
|
||||
.day {
|
||||
flex: none;
|
||||
|
|
@ -50,12 +66,15 @@
|
|||
}
|
||||
|
||||
.items {
|
||||
flex: 0 1 10rem;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
padding: 0 0.5rem 0.5rem 0.5rem;
|
||||
gap: 0.2rem;
|
||||
padding: 0.1rem;
|
||||
flex: 1 1 60%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
min-width: 0;
|
||||
|
||||
.item {
|
||||
flex: none;
|
||||
|
|
@ -64,7 +83,9 @@
|
|||
justify-content: center;
|
||||
height: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
border-radius: var(--border-radius);
|
||||
border-radius: calc(var(--border-radius) / 2);
|
||||
max-height: 100%;
|
||||
max-width: 100%;
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
|
|
@ -80,13 +101,11 @@
|
|||
}
|
||||
|
||||
.poster {
|
||||
flex: auto;
|
||||
z-index: 0;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
opacity: 1;
|
||||
border-radius: inherit
|
||||
}
|
||||
|
||||
.icon, .poster {
|
||||
|
|
@ -117,8 +136,11 @@
|
|||
|
||||
&.today {
|
||||
.heading {
|
||||
padding: 0.3rem;
|
||||
.day {
|
||||
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) {
|
||||
.disable-cell-items();
|
||||
}
|
||||
|
||||
@media @phone-portrait {
|
||||
.cell {
|
||||
.heading {
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
}
|
||||
.compact-items();
|
||||
.disable-cell-items();
|
||||
}
|
||||
|
||||
.items {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.more {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-height: @xxsmall) and (orientation: landscape) {
|
||||
@media @phone-landscape {
|
||||
.cell {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.items {
|
||||
display: none;
|
||||
}
|
||||
.compact-items();
|
||||
.disable-cell-items();
|
||||
}
|
||||
|
||||
.more {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 {
|
||||
gap: 0;
|
||||
|
||||
.heading {
|
||||
height: 2rem;
|
||||
|
||||
.day {
|
||||
padding: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 1px;
|
||||
grid-auto-rows: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -133,14 +133,14 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
discover.catalog === null ?
|
||||
<DelayedRenderer delay={500}>
|
||||
<div className={styles['message-container']}>
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
|
||||
<div className={styles['message-label']}>{t('NO_CATALOG_SELECTED')}</div>
|
||||
</div>
|
||||
</DelayedRenderer>
|
||||
:
|
||||
discover.catalog.content.type === 'Err' ?
|
||||
<div className={styles['message-container']}>
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
|
||||
<div className={styles['message-label']}>{discover.catalog.content.content}</div>
|
||||
</div>
|
||||
:
|
||||
|
|
|
|||
|
|
@ -296,7 +296,7 @@ const Intro = ({ queryParams }) => {
|
|||
<div className={styles['background-container']} />
|
||||
<div className={styles['heading-container']}>
|
||||
<div className={styles['logo-container']}>
|
||||
<Image className={styles['logo']} src={require('/images/logo.png')} alt={' '} />
|
||||
<Image className={styles['logo']} src={require('/assets/images/logo.png')} alt={' '} />
|
||||
</div>
|
||||
<div className={styles['title-container']}>
|
||||
{t('WEBSITE_SLOGAN_NEW_NEW')}
|
||||
|
|
@ -387,7 +387,7 @@ const Intro = ({ queryParams }) => {
|
|||
{
|
||||
state.form === SIGNUP_FORM ?
|
||||
<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>
|
||||
:
|
||||
null
|
||||
|
|
@ -395,7 +395,7 @@ const Intro = ({ queryParams }) => {
|
|||
{
|
||||
state.form === LOGIN_FORM ?
|
||||
<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>
|
||||
:
|
||||
null
|
||||
|
|
@ -403,7 +403,7 @@ const Intro = ({ queryParams }) => {
|
|||
{
|
||||
state.form === SIGNUP_FORM ?
|
||||
<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>
|
||||
:
|
||||
null
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
bottom: -1rem;
|
||||
left: -1rem;
|
||||
right: -1rem;
|
||||
background: url('/images/background_1.svg'), url('/images/background_2.svg');
|
||||
background: url('/assets/images/background_1.svg'), url('/assets/images/background_2.svg');
|
||||
background-color: var(--primary-background-color);
|
||||
background-position: bottom left, top right;
|
||||
background-size: 53%, 54%;
|
||||
|
|
@ -101,10 +101,6 @@
|
|||
color: var(--primary-foreground-color);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-button, .guest-login-button, .signup-form-button, .login-form-button {
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ const Library = ({ model, urlParams, queryParams }) => {
|
|||
<div className={styles['message-container']}>
|
||||
<Image
|
||||
className={styles['image']}
|
||||
src={require('/images/empty.png')}
|
||||
src={require('/assets/images/empty.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
<div className={styles['message-label']}>{model === 'library' ? t('LIBRARY_NOT_LOADED') : t('BOARD_CONTINUE_WATCHING_NOT_LOADED')}</div>
|
||||
|
|
@ -96,7 +96,7 @@ const Library = ({ model, urlParams, queryParams }) => {
|
|||
<div className={styles['message-container']}>
|
||||
<Image
|
||||
className={styles['image']}
|
||||
src={require('/images/empty.png')}
|
||||
src={require('/assets/images/empty.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
<div className={styles['message-label']}>{model === 'library' ? t('LIBRARY_EMPTY') : t('BOARD_CONTINUE_WATCHING_EMPTY')}</div>
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const Placeholder = () => {
|
|||
<div className={styles['image-container']}>
|
||||
<Image
|
||||
className={styles['image']}
|
||||
src={require('/images/library_placeholder.png')}
|
||||
src={require('/assets/images/library_placeholder.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -134,20 +134,20 @@ const MetaDetails = ({ urlParams, queryParams }) => {
|
|||
metaPath === null ?
|
||||
<DelayedRenderer delay={500}>
|
||||
<div className={styles['meta-message-container']}>
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
|
||||
<div className={styles['message-label']}>{t('ERR_NO_META_SELECTED')}</div>
|
||||
</div>
|
||||
</DelayedRenderer>
|
||||
:
|
||||
metaDetails.metaItem === null ?
|
||||
<div className={styles['meta-message-container']}>
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
|
||||
<div className={styles['message-label']}>{t('ERR_NO_ADDONS_FOR_META')}</div>
|
||||
</div>
|
||||
:
|
||||
metaDetails.metaItem.content.type === 'Err' ?
|
||||
<div className={styles['meta-message-container']}>
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
|
||||
<div className={styles['message-label']}>{t('ERR_NO_META_FOUND')}</div>
|
||||
</div>
|
||||
:
|
||||
|
|
@ -194,6 +194,7 @@ const MetaDetails = ({ urlParams, queryParams }) => {
|
|||
metaItem={metaDetails.metaItem}
|
||||
libraryItem={metaDetails.libraryItem}
|
||||
season={season}
|
||||
selectedVideoId={metaDetails.libraryItem?.state?.video_id}
|
||||
seasonOnSelect={seasonOnSelect}
|
||||
toggleNotifications={toggleNotifications}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
|
|||
<SeasonEpisodePicker className={styles['search']} onSubmit={handleEpisodePicker} />
|
||||
: null
|
||||
}
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
|
||||
<div className={styles['label']}>{t('ERR_NO_ADDONS_FOR_STREAMS')}</div>
|
||||
</div>
|
||||
:
|
||||
|
|
@ -148,7 +148,7 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
|
|||
<div className={styles['label']}>{t('UPCOMING')}...</div>
|
||||
: null
|
||||
}
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
|
||||
<div className={styles['label']}>{t('NO_STREAM')}</div>
|
||||
{
|
||||
showInstallAddonsButton ?
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ const SeasonsBar = require('./SeasonsBar');
|
|||
const { default: EpisodePicker } = require('../EpisodePicker');
|
||||
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 profile = useProfile();
|
||||
|
||||
const showNotificationsToggle = React.useMemo(() => {
|
||||
return metaItem?.content?.content?.inLibrary && metaItem?.content?.content?.videos?.length;
|
||||
}, [metaItem]);
|
||||
|
|
@ -123,7 +124,7 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
|
|||
metaItem.content.type === 'Err' || videosForSeason.length === 0 ?
|
||||
<div className={styles['message-container']}>
|
||||
<EpisodePicker className={styles['episode-picker']} onSubmit={onSeasonSearch} />
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
|
||||
<div className={styles['label']}>{t('ERR_NO_VIDEOS_FOR_META')}</div>
|
||||
</div>
|
||||
:
|
||||
|
|
@ -178,6 +179,7 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
|
|||
deepLinks={video.deepLinks}
|
||||
scheduled={video.scheduled}
|
||||
seasonWatched={seasonWatched}
|
||||
selected={video.id === selectedVideoId}
|
||||
onMarkVideoAsWatched={onMarkVideoAsWatched}
|
||||
onMarkSeasonAsWatched={onMarkSeasonAsWatched}
|
||||
/>
|
||||
|
|
@ -195,6 +197,7 @@ VideosList.propTypes = {
|
|||
metaItem: PropTypes.object,
|
||||
libraryItem: PropTypes.object,
|
||||
season: PropTypes.number,
|
||||
selectedVideoId: PropTypes.string,
|
||||
seasonOnSelect: PropTypes.func,
|
||||
toggleNotifications: PropTypes.func,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ const NotFound = () => {
|
|||
<div className={styles['not-found-content']}>
|
||||
<Image
|
||||
className={styles['not-found-image']}
|
||||
src={require('/images/empty.png')}
|
||||
src={require('/assets/images/empty.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
<div className={styles['not-found-label']}>{t('PAGE_NOT_FOUND')}</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 25rem;
|
||||
width: 16rem;
|
||||
|
||||
.header {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const BufferingLoader = React.forwardRef(({ className, logo }, ref) => {
|
|||
className={styles['buffering-loader']}
|
||||
src={logo}
|
||||
alt={' '}
|
||||
fallbackSrc={require('/images/stremio_symbol.png')}
|
||||
fallbackSrc={require('/assets/images/stremio_symbol.png')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@ const OptionsMenu = ({ className, stream, playbackDevices, extraSubtitlesTracks,
|
|||
}
|
||||
}, [streamingUrl, downloadUrl]);
|
||||
const onDownloadVideoButtonClick = React.useCallback(() => {
|
||||
if (streamingUrl || downloadUrl) {
|
||||
platform.openExternal(streamingUrl || downloadUrl);
|
||||
if (downloadUrl || streamingUrl ) {
|
||||
platform.openExternal(downloadUrl || streamingUrl);
|
||||
}
|
||||
}, [streamingUrl, downloadUrl]);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const langs = require('langs');
|
|||
const { useTranslation } = require('react-i18next');
|
||||
const { useRouteFocused } = require('stremio-router');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { onFileDrop, useSettings, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS, useShell } = require('stremio/common');
|
||||
const { onFileDrop, useSettings, useProfile, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS, useShell, usePlatform, onShortcut } = require('stremio/common');
|
||||
const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components');
|
||||
const BufferingLoader = require('./BufferingLoader');
|
||||
const VolumeChangeIndicator = require('./VolumeChangeIndicator');
|
||||
|
|
@ -29,6 +29,9 @@ const styles = require('./styles');
|
|||
const Video = require('./Video');
|
||||
const { default: Indicator } = require('./Indicator/Indicator');
|
||||
|
||||
const findTrackByLang = (tracks, lang) => tracks.find((track) => track.lang === lang || langs.where('1', track.lang)?.[2] === lang);
|
||||
const findTrackById = (tracks, id) => tracks.find((track) => track.id === id);
|
||||
|
||||
const Player = ({ urlParams, queryParams }) => {
|
||||
const { t } = useTranslation();
|
||||
const services = useServices();
|
||||
|
|
@ -36,13 +39,14 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
const forceTranscoding = React.useMemo(() => {
|
||||
return queryParams.has('forceTranscoding');
|
||||
}, [queryParams]);
|
||||
|
||||
const [player, videoParamsChanged, timeChanged, seek, pausedChanged, ended, nextVideo] = usePlayer(urlParams);
|
||||
const [settings, updateSettings] = useSettings();
|
||||
const profile = useProfile();
|
||||
const [player, videoParamsChanged, streamStateChanged, timeChanged, seek, pausedChanged, ended, nextVideo] = usePlayer(urlParams);
|
||||
const [settings] = useSettings();
|
||||
const streamingServer = useStreamingServer();
|
||||
const statistics = useStatistics(player, streamingServer);
|
||||
const video = useVideo();
|
||||
const routeFocused = useRouteFocused();
|
||||
const platform = usePlatform();
|
||||
const toast = useToast();
|
||||
|
||||
const [seeking, setSeeking] = React.useState(false);
|
||||
|
|
@ -92,19 +96,16 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
const isNavigating = React.useRef(false);
|
||||
|
||||
const onImplementationChanged = React.useCallback(() => {
|
||||
video.setProp('subtitlesSize', settings.subtitlesSize);
|
||||
video.setProp('subtitlesOffset', settings.subtitlesOffset);
|
||||
video.setProp('subtitlesTextColor', settings.subtitlesTextColor);
|
||||
video.setProp('subtitlesBackgroundColor', settings.subtitlesBackgroundColor);
|
||||
video.setProp('subtitlesOutlineColor', settings.subtitlesOutlineColor);
|
||||
video.setProp('extraSubtitlesSize', settings.subtitlesSize);
|
||||
video.setProp('extraSubtitlesOffset', settings.subtitlesOffset);
|
||||
video.setProp('extraSubtitlesTextColor', settings.subtitlesTextColor);
|
||||
video.setProp('extraSubtitlesBackgroundColor', settings.subtitlesBackgroundColor);
|
||||
video.setProp('extraSubtitlesOutlineColor', settings.subtitlesOutlineColor);
|
||||
}, [settings.subtitlesSize, settings.subtitlesOffset, settings.subtitlesTextColor, settings.subtitlesBackgroundColor, settings.subtitlesOutlineColor]);
|
||||
video.setSubtitlesSize(settings.subtitlesSize);
|
||||
video.setSubtitlesOffset(settings.subtitlesOffset);
|
||||
video.setSubtitlesTextColor(settings.subtitlesTextColor);
|
||||
video.setSubtitlesBackgroundColor(settings.subtitlesBackgroundColor);
|
||||
video.setSubtitlesOutlineColor(settings.subtitlesOutlineColor);
|
||||
}, [settings]);
|
||||
|
||||
const handleNextVideoNavigation = React.useCallback((deepLinks) => {
|
||||
const handleNextVideoNavigation = React.useCallback((deepLinks, bingeWatching, ended) => {
|
||||
if (ended) {
|
||||
if (bingeWatching) {
|
||||
if (deepLinks.player) {
|
||||
isNavigating.current = true;
|
||||
window.location.replace(deepLinks.player);
|
||||
|
|
@ -112,6 +113,18 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
isNavigating.current = true;
|
||||
window.location.replace(deepLinks.metaDetailsStreams);
|
||||
}
|
||||
} else {
|
||||
window.history.back();
|
||||
}
|
||||
} else {
|
||||
if (deepLinks.player) {
|
||||
isNavigating.current = true;
|
||||
window.location.replace(deepLinks.player);
|
||||
} else if (deepLinks.metaDetailsStreams) {
|
||||
isNavigating.current = true;
|
||||
window.location.replace(deepLinks.metaDetailsStreams);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onEnded = React.useCallback(() => {
|
||||
|
|
@ -126,7 +139,8 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
nextVideo();
|
||||
|
||||
const deepLinks = window.playerNextVideo.deepLinks;
|
||||
handleNextVideoNavigation(deepLinks);
|
||||
handleNextVideoNavigation(deepLinks, profile.settings.bingeWatching, true);
|
||||
|
||||
} else {
|
||||
window.history.back();
|
||||
}
|
||||
|
|
@ -174,53 +188,71 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
}, []);
|
||||
|
||||
const onPlayRequested = React.useCallback(() => {
|
||||
video.setProp('paused', false);
|
||||
video.setPaused(false);
|
||||
setSeeking(false);
|
||||
}, []);
|
||||
|
||||
const onPlayRequestedDebounced = React.useCallback(debounce(onPlayRequested, 200), []);
|
||||
|
||||
const onPauseRequested = React.useCallback(() => {
|
||||
video.setProp('paused', true);
|
||||
video.setPaused(true);
|
||||
}, []);
|
||||
|
||||
const onPauseRequestedDebounced = React.useCallback(debounce(onPauseRequested, 200), []);
|
||||
const onMuteRequested = React.useCallback(() => {
|
||||
video.setProp('muted', true);
|
||||
video.setMuted(true);
|
||||
}, []);
|
||||
|
||||
const onUnmuteRequested = React.useCallback(() => {
|
||||
video.setProp('muted', false);
|
||||
video.setMuted(false);
|
||||
}, []);
|
||||
|
||||
const onVolumeChangeRequested = React.useCallback((volume) => {
|
||||
video.setProp('volume', volume);
|
||||
video.setVolume(volume);
|
||||
}, []);
|
||||
|
||||
const onSeekRequested = React.useCallback((time) => {
|
||||
video.setProp('time', time);
|
||||
video.setTime(time);
|
||||
seek(time, video.state.duration, video.state.manifest?.name);
|
||||
}, [video.state.duration, video.state.manifest]);
|
||||
|
||||
const onPlaybackSpeedChanged = React.useCallback((rate) => {
|
||||
video.setProp('playbackSpeed', rate);
|
||||
video.setPlaybackSpeed(rate);
|
||||
}, []);
|
||||
|
||||
const onSubtitlesTrackSelected = React.useCallback((id) => {
|
||||
video.setSubtitlesTrack(id);
|
||||
}, []);
|
||||
streamStateChanged({
|
||||
subtitleTrack: {
|
||||
id,
|
||||
embedded: true,
|
||||
},
|
||||
});
|
||||
}, [streamStateChanged]);
|
||||
|
||||
const onExtraSubtitlesTrackSelected = React.useCallback((id) => {
|
||||
video.setExtraSubtitlesTrack(id);
|
||||
}, []);
|
||||
streamStateChanged({
|
||||
subtitleTrack: {
|
||||
id,
|
||||
embedded: false,
|
||||
},
|
||||
});
|
||||
}, [streamStateChanged]);
|
||||
|
||||
const onAudioTrackSelected = React.useCallback((id) => {
|
||||
video.setProp('selectedAudioTrackId', id);
|
||||
}, []);
|
||||
video.setAudioTrack(id);
|
||||
streamStateChanged({
|
||||
audioTrack: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
}, [streamStateChanged]);
|
||||
|
||||
const onExtraSubtitlesDelayChanged = React.useCallback((delay) => {
|
||||
video.setProp('extraSubtitlesDelay', delay);
|
||||
}, []);
|
||||
video.setSubtitlesDelay(delay);
|
||||
streamStateChanged({ subtitleDelay: delay });
|
||||
}, [streamStateChanged]);
|
||||
|
||||
const onIncreaseSubtitlesDelay = React.useCallback(() => {
|
||||
const delay = video.state.extraSubtitlesDelay + 250;
|
||||
|
|
@ -233,8 +265,9 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
}, [video.state.extraSubtitlesDelay, onExtraSubtitlesDelayChanged]);
|
||||
|
||||
const onSubtitlesSizeChanged = React.useCallback((size) => {
|
||||
updateSettings({ subtitlesSize: size });
|
||||
}, [updateSettings]);
|
||||
video.setSubtitlesSize(size);
|
||||
streamStateChanged({ subtitleSize: size });
|
||||
}, [streamStateChanged]);
|
||||
|
||||
const onUpdateSubtitlesSize = React.useCallback((delta) => {
|
||||
const sizeIndex = CONSTANTS.SUBTITLES_SIZES.indexOf(video.state.subtitlesSize);
|
||||
|
|
@ -243,8 +276,9 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
}, [video.state.subtitlesSize, onSubtitlesSizeChanged]);
|
||||
|
||||
const onSubtitlesOffsetChanged = React.useCallback((offset) => {
|
||||
updateSettings({ subtitlesOffset: offset });
|
||||
}, [updateSettings]);
|
||||
video.setSubtitlesOffset(offset);
|
||||
streamStateChanged({ subtitleOffset: offset });
|
||||
}, [streamStateChanged]);
|
||||
|
||||
const onDismissNextVideoPopup = React.useCallback(() => {
|
||||
closeNextVideoPopup();
|
||||
|
|
@ -256,9 +290,9 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
nextVideo();
|
||||
|
||||
const deepLinks = player.nextVideo.deepLinks;
|
||||
handleNextVideoNavigation(deepLinks);
|
||||
handleNextVideoNavigation(deepLinks, profile.settings.bingeWatching, false);
|
||||
}
|
||||
}, [player.nextVideo, handleNextVideoNavigation]);
|
||||
}, [player.nextVideo, handleNextVideoNavigation, profile.settings]);
|
||||
|
||||
const onVideoClick = React.useCallback(() => {
|
||||
if (video.state.paused !== null) {
|
||||
|
|
@ -322,10 +356,10 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
setError(null);
|
||||
video.unload();
|
||||
|
||||
if (player.selected && streamingServer.settings?.type !== 'Loading') {
|
||||
if (player.selected && player.stream?.type === 'Ready' && streamingServer.settings?.type !== 'Loading') {
|
||||
video.load({
|
||||
stream: {
|
||||
...player.selected.stream,
|
||||
...player.stream.content,
|
||||
subtitles: Array.isArray(player.selected.stream.subtitles) ?
|
||||
player.selected.stream.subtitles.map((subtitles) => ({
|
||||
...subtitles,
|
||||
|
|
@ -345,6 +379,9 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
forceTranscoding: forceTranscoding || casting,
|
||||
maxAudioChannels: settings.surroundSound ? 32 : 2,
|
||||
hardwareDecoding: settings.hardwareDecoding,
|
||||
assSubtitlesStyling: settings.assSubtitlesStyling,
|
||||
videoMode: settings.videoMode,
|
||||
platform: platform.name,
|
||||
streamingServerURL: streamingServer.baseUrl ?
|
||||
casting ?
|
||||
streamingServer.baseUrl
|
||||
|
|
@ -358,7 +395,7 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
shellTransport: services.shell.active ? services.shell.transport : null,
|
||||
});
|
||||
}
|
||||
}, [streamingServer.baseUrl, player.selected, forceTranscoding, casting]);
|
||||
}, [streamingServer.baseUrl, player.selected, player.stream, forceTranscoding, casting]);
|
||||
React.useEffect(() => {
|
||||
if (video.state.stream !== null) {
|
||||
const tracks = player.subtitles.map((subtitles) => ({
|
||||
|
|
@ -369,31 +406,6 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
}
|
||||
}, [player.subtitles, video.state.stream]);
|
||||
|
||||
React.useEffect(() => {
|
||||
video.setProp('subtitlesSize', settings.subtitlesSize);
|
||||
video.setProp('extraSubtitlesSize', settings.subtitlesSize);
|
||||
}, [settings.subtitlesSize]);
|
||||
|
||||
React.useEffect(() => {
|
||||
video.setProp('subtitlesOffset', settings.subtitlesOffset);
|
||||
video.setProp('extraSubtitlesOffset', settings.subtitlesOffset);
|
||||
}, [settings.subtitlesOffset]);
|
||||
|
||||
React.useEffect(() => {
|
||||
video.setProp('subtitlesTextColor', settings.subtitlesTextColor);
|
||||
video.setProp('extraSubtitlesTextColor', settings.subtitlesTextColor);
|
||||
}, [settings.subtitlesTextColor]);
|
||||
|
||||
React.useEffect(() => {
|
||||
video.setProp('subtitlesBackgroundColor', settings.subtitlesBackgroundColor);
|
||||
video.setProp('extraSubtitlesBackgroundColor', settings.subtitlesBackgroundColor);
|
||||
}, [settings.subtitlesBackgroundColor]);
|
||||
|
||||
React.useEffect(() => {
|
||||
video.setProp('subtitlesOutlineColor', settings.subtitlesOutlineColor);
|
||||
video.setProp('extraSubtitlesOutlineColor', settings.subtitlesOutlineColor);
|
||||
}, [settings.subtitlesOutlineColor]);
|
||||
|
||||
React.useEffect(() => {
|
||||
!seeking && timeChanged(video.state.time, video.state.duration, video.state.manifest?.name);
|
||||
}, [video.state.time, video.state.duration, video.state.manifest, seeking]);
|
||||
|
|
@ -409,7 +421,7 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
}, [video.state.videoParams]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!!settings.bingeWatching && player.nextVideo !== null && !nextVideoPopupDismissed.current) {
|
||||
if (player.nextVideo !== null && !nextVideoPopupDismissed.current) {
|
||||
if (video.state.time !== null && video.state.duration !== null && video.state.time < video.state.duration && (video.state.duration - video.state.time) <= settings.nextVideoNotificationDuration) {
|
||||
openNextVideoPopup();
|
||||
} else {
|
||||
|
|
@ -426,41 +438,69 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
}
|
||||
}, [player.nextVideo, video.state.time, video.state.duration]);
|
||||
|
||||
// Auto subtitles track selection
|
||||
React.useEffect(() => {
|
||||
if (!defaultSubtitlesSelected.current) {
|
||||
const findTrackByLang = (tracks, lang) => tracks.find((track) => track.lang === lang || langs.where('1', track.lang)?.[2] === lang);
|
||||
|
||||
if (settings.subtitlesLanguage === null) {
|
||||
onSubtitlesTrackSelected(null);
|
||||
onExtraSubtitlesTrackSelected(null);
|
||||
video.setSubtitlesTrack(null);
|
||||
video.setExtraSubtitlesTrack(null);
|
||||
defaultSubtitlesSelected.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const subtitlesTrack = findTrackByLang(video.state.subtitlesTracks, settings.subtitlesLanguage);
|
||||
const extraSubtitlesTrack = findTrackByLang(video.state.extraSubtitlesTracks, settings.subtitlesLanguage);
|
||||
const savedTrackId = player.streamState?.subtitleTrack?.id;
|
||||
const subtitlesTrack = savedTrackId ?
|
||||
findTrackById(video.state.subtitlesTracks, savedTrackId) :
|
||||
findTrackByLang(video.state.subtitlesTracks, settings.subtitlesLanguage);
|
||||
|
||||
const extraSubtitlesTrack = savedTrackId ?
|
||||
findTrackById(video.state.extraSubtitlesTracks, savedTrackId) :
|
||||
findTrackByLang(video.state.extraSubtitlesTracks, settings.subtitlesLanguage);
|
||||
|
||||
if (subtitlesTrack && subtitlesTrack.id) {
|
||||
onSubtitlesTrackSelected(subtitlesTrack.id);
|
||||
video.setSubtitlesTrack(subtitlesTrack.id);
|
||||
defaultSubtitlesSelected.current = true;
|
||||
} else if (extraSubtitlesTrack && extraSubtitlesTrack.id) {
|
||||
onExtraSubtitlesTrackSelected(extraSubtitlesTrack.id);
|
||||
video.setExtraSubtitlesTrack(extraSubtitlesTrack.id);
|
||||
defaultSubtitlesSelected.current = true;
|
||||
}
|
||||
}
|
||||
}, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks]);
|
||||
}, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks, player.streamState]);
|
||||
|
||||
// Auto audio track selection
|
||||
React.useEffect(() => {
|
||||
if (!defaultAudioTrackSelected.current) {
|
||||
const findTrackByLang = (tracks, lang) => tracks.find((track) => track.lang === lang || langs.where('1', track.lang)?.[2] === lang);
|
||||
const audioTrack = findTrackByLang(video.state.audioTracks, settings.audioLanguage);
|
||||
const savedTrackId = player.streamState?.audioTrack?.id;
|
||||
const audioTrack = savedTrackId ?
|
||||
findTrackById(video.state.audioTracks, savedTrackId) :
|
||||
findTrackByLang(video.state.audioTracks, settings.audioLanguage);
|
||||
|
||||
if (audioTrack && audioTrack.id) {
|
||||
onAudioTrackSelected(audioTrack.id);
|
||||
video.setAudioTrack(audioTrack.id);
|
||||
defaultAudioTrackSelected.current = true;
|
||||
}
|
||||
}
|
||||
}, [video.state.audioTracks]);
|
||||
}, [video.state.audioTracks, player.streamState]);
|
||||
|
||||
// Saved subtitles settings
|
||||
React.useEffect(() => {
|
||||
if (video.state.stream !== null) {
|
||||
const delay = player.streamState?.subtitleDelay;
|
||||
if (typeof delay === 'number') {
|
||||
video.setSubtitlesDelay(delay);
|
||||
}
|
||||
|
||||
const size = player.streamState?.subtitleSize;
|
||||
if (typeof size === 'number') {
|
||||
video.setSubtitlesSize(size);
|
||||
}
|
||||
|
||||
const offset = player.streamState?.subtitleOffset;
|
||||
if (typeof offset === 'number') {
|
||||
video.setSubtitlesOffset(offset);
|
||||
}
|
||||
}
|
||||
}, [video.state.stream, player.streamState]);
|
||||
|
||||
React.useEffect(() => {
|
||||
defaultSubtitlesSelected.current = false;
|
||||
|
|
@ -546,8 +586,8 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
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 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;
|
||||
|
|
@ -579,10 +619,7 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
navigator.mediaSession.setActionHandler('nexttrack', nexVideoCallback);
|
||||
}, [player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const onKeyDown = (event) => {
|
||||
switch (event.code) {
|
||||
case 'Space': {
|
||||
onShortcut('playPause', () => {
|
||||
if (!menusOpen && !nextVideoPopupOpen && video.state.paused !== null) {
|
||||
if (video.state.paused) {
|
||||
onPlayRequested();
|
||||
|
|
@ -591,105 +628,90 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
onPauseRequested();
|
||||
}
|
||||
}
|
||||
}, [menusOpen, nextVideoPopupOpen, video.state.paused, onPlayRequested, onPauseRequested]);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
onShortcut('seekForward', (combo) => {
|
||||
if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) {
|
||||
const seekDuration = event.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration;
|
||||
const seekDuration = combo === 1 ? settings.seekShortTimeDuration : settings.seekTimeDuration;
|
||||
setSeeking(true);
|
||||
onSeekRequested(video.state.time + seekDuration);
|
||||
}
|
||||
}, [menusOpen, nextVideoPopupOpen, video.state.time, onSeekRequested]);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
onShortcut('seekBackward', (combo) => {
|
||||
if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) {
|
||||
const seekDuration = event.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration;
|
||||
const seekDuration = combo === 1 ? settings.seekShortTimeDuration : settings.seekTimeDuration;
|
||||
setSeeking(true);
|
||||
onSeekRequested(video.state.time - seekDuration);
|
||||
}
|
||||
}, [menusOpen, nextVideoPopupOpen, video.state.time, onSeekRequested]);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'ArrowUp': {
|
||||
onShortcut('mute', () => {
|
||||
video.state.muted === true ? onUnmuteRequested() : onMuteRequested();
|
||||
}, [video.state.muted]);
|
||||
|
||||
onShortcut('volumeUp', () => {
|
||||
if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
|
||||
onVolumeChangeRequested(Math.min(video.state.volume + 5, 200));
|
||||
}
|
||||
}, [menusOpen, nextVideoPopupOpen, video.state.volume]);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'ArrowDown': {
|
||||
onShortcut('volumeDown', () => {
|
||||
if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
|
||||
onVolumeChangeRequested(Math.max(video.state.volume - 5, 0));
|
||||
onVolumeChangeRequested(Math.min(video.state.volume - 5, 200));
|
||||
}
|
||||
}, [menusOpen, nextVideoPopupOpen, video.state.volume]);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'KeyS': {
|
||||
onShortcut('subtitlesDelay', (combo) => {
|
||||
combo === 1 ? onIncreaseSubtitlesDelay() : onDecreaseSubtitlesDelay();
|
||||
}, [onIncreaseSubtitlesDelay, onDecreaseSubtitlesDelay]);
|
||||
|
||||
onShortcut('subtitlesSize', (combo) => {
|
||||
combo === 1 ? onUpdateSubtitlesSize(-1) : onUpdateSubtitlesSize(1);
|
||||
}, [onUpdateSubtitlesSize, onUpdateSubtitlesSize]);
|
||||
|
||||
onShortcut('subtitlesMenu', () => {
|
||||
closeMenus();
|
||||
if ((Array.isArray(video.state.subtitlesTracks) && video.state.subtitlesTracks.length > 0) ||
|
||||
(Array.isArray(video.state.extraSubtitlesTracks) && video.state.extraSubtitlesTracks.length > 0)) {
|
||||
if (video.state?.subtitlesTracks?.length > 0 || video.state?.extraSubtitlesTracks?.length > 0) {
|
||||
toggleSubtitlesMenu();
|
||||
}
|
||||
}, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks, toggleSubtitlesMenu]);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'KeyA': {
|
||||
onShortcut('audioMenu', () => {
|
||||
closeMenus();
|
||||
if (Array.isArray(video.state.audioTracks) && video.state.audioTracks.length > 0) {
|
||||
if (video.state?.audioTracks?.length > 0) {
|
||||
toggleAudioMenu();
|
||||
}
|
||||
}, [video.state.audioTracks, toggleAudioMenu]);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'KeyI': {
|
||||
onShortcut('infoMenu', () => {
|
||||
closeMenus();
|
||||
if (player.metaItem !== null && player.metaItem.type === 'Ready') {
|
||||
if (player.metaItem?.type === 'Ready') {
|
||||
toggleSideDrawer();
|
||||
}
|
||||
}, [player.metaItem, toggleSideDrawer]);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'KeyR': {
|
||||
onShortcut('speedMenu', () => {
|
||||
closeMenus();
|
||||
if (video.state.playbackSpeed !== null) {
|
||||
toggleSpeedMenu();
|
||||
}
|
||||
}, [video.state.playbackSpeed, toggleSpeedMenu]);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'KeyD': {
|
||||
onShortcut('statisticsMenu', () => {
|
||||
closeMenus();
|
||||
if (streamingServer.statistics !== null && streamingServer.statistics.type !== 'Err' && player.selected && typeof player.selected.stream.infoHash === 'string' && typeof player.selected.stream.fileIdx === 'number') {
|
||||
const stream = player.selected?.stream;
|
||||
if (streamingServer?.statistics?.type !== 'Err' && typeof stream === 'string' && typeof stream === 'number') {
|
||||
toggleStatisticsMenu();
|
||||
}
|
||||
}, [player.selected, streamingServer.statistics, toggleStatisticsMenu]);
|
||||
|
||||
break;
|
||||
}
|
||||
case 'KeyG': {
|
||||
onDecreaseSubtitlesDelay();
|
||||
break;
|
||||
}
|
||||
case 'KeyH': {
|
||||
onIncreaseSubtitlesDelay();
|
||||
break;
|
||||
}
|
||||
case 'Minus': {
|
||||
onUpdateSubtitlesSize(-1);
|
||||
break;
|
||||
}
|
||||
case 'Equal': {
|
||||
onUpdateSubtitlesSize(1);
|
||||
break;
|
||||
}
|
||||
case 'Escape': {
|
||||
onShortcut('exit', () => {
|
||||
closeMenus();
|
||||
!settings.escExitFullscreen && window.history.back();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [settings.escExitFullscreen]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const onKeyUp = (event) => {
|
||||
if (event.code === 'ArrowRight' || event.code === 'ArrowLeft') {
|
||||
setSeeking(false);
|
||||
|
|
@ -707,39 +729,14 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
}
|
||||
};
|
||||
if (routeFocused) {
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('wheel', onWheel);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('wheel', onWheel);
|
||||
};
|
||||
}, [
|
||||
player.metaItem,
|
||||
player.selected,
|
||||
streamingServer.statistics,
|
||||
settings.seekTimeDuration,
|
||||
settings.seekShortTimeDuration,
|
||||
settings.escExitFullscreen,
|
||||
routeFocused,
|
||||
menusOpen,
|
||||
nextVideoPopupOpen,
|
||||
video.state.paused,
|
||||
video.state.time,
|
||||
video.state.volume,
|
||||
video.state.audioTracks,
|
||||
video.state.subtitlesTracks,
|
||||
video.state.extraSubtitlesTracks,
|
||||
video.state.playbackSpeed,
|
||||
toggleSubtitlesMenu,
|
||||
toggleStatisticsMenu,
|
||||
toggleSideDrawer,
|
||||
onDecreaseSubtitlesDelay,
|
||||
onIncreaseSubtitlesDelay,
|
||||
onUpdateSubtitlesSize,
|
||||
]);
|
||||
}, [routeFocused, menusOpen, video.state.volume]);
|
||||
|
||||
React.useEffect(() => {
|
||||
video.events.on('error', onError);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// 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 Icon from '@stremio/stremio-icons/react';
|
||||
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 { core } = useServices();
|
||||
const [season, setSeason] = useState<number>(seriesInfo?.season);
|
||||
const selectedVideoRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
|
||||
|
||||
const metaItem = useMemo(() => {
|
||||
return seriesInfo ?
|
||||
{
|
||||
|
|
@ -78,11 +79,9 @@ const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, classNa
|
|||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const onTransitionEnd = () => {
|
||||
selectedVideoRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
const onTransitionEnd = useCallback(() => {
|
||||
setSelectedVideoId(selected);
|
||||
}, [selected]);
|
||||
|
||||
return (
|
||||
<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) => (
|
||||
<Video
|
||||
key={index}
|
||||
ref={video.id === selected ? selectedVideoRef : null}
|
||||
className={styles['video']}
|
||||
id={video.id}
|
||||
title={video.title}
|
||||
|
|
@ -128,6 +126,7 @@ const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, classNa
|
|||
progress={video.progress}
|
||||
deepLinks={video.deepLinks}
|
||||
scheduled={video.scheduled}
|
||||
selected={video.id === selectedVideoId}
|
||||
onMarkVideoAsWatched={onMarkVideoAsWatched}
|
||||
onMarkSeasonAsWatched={onMarkSeasonAsWatched}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classNames from 'classnames';
|
||||
import Icon from '@stremio/stremio-icons/react';
|
||||
|
|
@ -37,6 +37,18 @@ const Stepper = ({ className, label, value, unit, step, min, max, disabled, onCh
|
|||
timeout.cancel();
|
||||
};
|
||||
|
||||
const decreaseDisabled = useMemo(() => {
|
||||
return disabled || typeof value !== 'number' || (typeof min === 'number' && value <= min);
|
||||
}, [disabled, min, value]);
|
||||
|
||||
const increaseDisabled = useMemo(() => {
|
||||
return disabled || typeof value !== 'number' || (typeof max === 'number' && value >= max);
|
||||
}, [disabled, max, value]);
|
||||
|
||||
const valueLabel = useMemo(() => {
|
||||
return (disabled || typeof value !== 'number') ? '--' : `${value}${unit}`;
|
||||
}, [disabled, value, unit]);
|
||||
|
||||
const updateValue = useCallback((delta: number) => {
|
||||
onChange(clamp(localValue.current + delta, min, max));
|
||||
}, [onChange]);
|
||||
|
|
@ -72,7 +84,7 @@ const Stepper = ({ className, label, value, unit, step, min, max, disabled, onCh
|
|||
</div>
|
||||
<div className={styles['content']}>
|
||||
<Button
|
||||
className={classNames(styles['button'], { 'disabled': disabled })}
|
||||
className={classNames(styles['button'], { 'disabled': decreaseDisabled })}
|
||||
onMouseDown={onDecrementMouseDown}
|
||||
onMouseUp={onDecrementMouseUp}
|
||||
onMouseLeave={cancel}
|
||||
|
|
@ -80,10 +92,10 @@ const Stepper = ({ className, label, value, unit, step, min, max, disabled, onCh
|
|||
<Icon className={styles['icon']} name={'remove'} />
|
||||
</Button>
|
||||
<div className={styles['value']}>
|
||||
{ disabled ? '--' : `${value}${unit}` }
|
||||
{ valueLabel }
|
||||
</div>
|
||||
<Button
|
||||
className={classNames(styles['button'], { 'disabled': disabled })}
|
||||
className={classNames(styles['button'], { 'disabled': increaseDisabled })}
|
||||
onMouseDown={onIncrementMouseDown}
|
||||
onMouseUp={onIncrementMouseUp}
|
||||
onMouseLeave={cancel}
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ const SubtitlesMenu = React.memo((props) => {
|
|||
/>
|
||||
<Stepper
|
||||
className={styles['stepper']}
|
||||
label={'PLAYER_SUBTITLES_VERTICAL_POSIITON'}
|
||||
label={'PLAYER_SUBTITLES_VERTICAL_POSITION'}
|
||||
value={props.selectedSubtitlesTrackId ? props.subtitlesOffset : props.selectedExtraSubtitlesTrackId ? props.extraSubtitlesOffset : null}
|
||||
unit={'%'}
|
||||
step={1}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,9 @@ const usePlayer = (urlParams) => {
|
|||
};
|
||||
}
|
||||
}, [urlParams]);
|
||||
|
||||
const player = useModelState({ model: 'player', action, map });
|
||||
|
||||
const videoParamsChanged = React.useCallback((videoParams) => {
|
||||
core.transport.dispatch({
|
||||
action: 'Player',
|
||||
|
|
@ -153,8 +156,22 @@ const usePlayer = (urlParams) => {
|
|||
}, 'player');
|
||||
}, []);
|
||||
|
||||
const player = useModelState({ model: 'player', action, map });
|
||||
return [player, videoParamsChanged, timeChanged, seek, pausedChanged, ended, nextVideo];
|
||||
const streamStateChanged = React.useCallback((partialStreamState) => {
|
||||
return core.transport.dispatch({
|
||||
action: 'Player',
|
||||
args: {
|
||||
action: 'StreamStateChanged',
|
||||
args: {
|
||||
state: {
|
||||
...player.streamState,
|
||||
...partialStreamState,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, 'player');
|
||||
}, [player.streamState]);
|
||||
|
||||
return [player, videoParamsChanged, streamStateChanged, timeChanged, seek, pausedChanged, ended, nextVideo];
|
||||
};
|
||||
|
||||
module.exports = usePlayer;
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ const useStatistics = (player, streamingServer) => {
|
|||
const { core } = useServices();
|
||||
|
||||
const stream = React.useMemo(() => {
|
||||
return player.selected?.stream ?
|
||||
player.selected.stream
|
||||
:
|
||||
null;
|
||||
}, [player.selected]);
|
||||
if (player.stream?.type === 'Ready') {
|
||||
return player.stream.content;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, [player.stream]);
|
||||
|
||||
const infoHash = React.useMemo(() => {
|
||||
return stream?.infoHash ?
|
||||
|
|
|
|||
|
|
@ -94,6 +94,30 @@ const useVideo = () => {
|
|||
dispatch({ type: 'setProp', propName: name, propValue: value });
|
||||
};
|
||||
|
||||
const setPaused = (state) => {
|
||||
setProp('paused', state);
|
||||
};
|
||||
|
||||
const setVolume = (volume) => {
|
||||
setProp('volume', volume);
|
||||
};
|
||||
|
||||
const setMuted = (state) => {
|
||||
setProp('muted', state);
|
||||
};
|
||||
|
||||
const setTime = (time) => {
|
||||
setProp('time', time);
|
||||
};
|
||||
|
||||
const setPlaybackSpeed = (rate) => {
|
||||
setProp('playbackSpeed', rate);
|
||||
};
|
||||
|
||||
const setAudioTrack = (id) => {
|
||||
setProp('selectedAudioTrackId', id);
|
||||
};
|
||||
|
||||
const setSubtitlesTrack = (id) => {
|
||||
setProp('selectedSubtitlesTrackId', id);
|
||||
setProp('selectedExtraSubtitlesTrackId', null);
|
||||
|
|
@ -104,6 +128,35 @@ const useVideo = () => {
|
|||
setProp('selectedExtraSubtitlesTrackId', id);
|
||||
};
|
||||
|
||||
const setSubtitlesDelay = (delay) => {
|
||||
setProp('extraSubtitlesDelay', delay);
|
||||
};
|
||||
|
||||
const setSubtitlesSize = (size) => {
|
||||
setProp('subtitlesSize', size);
|
||||
setProp('extraSubtitlesSize', size);
|
||||
};
|
||||
|
||||
const setSubtitlesOffset = (offset) => {
|
||||
setProp('subtitlesOffset', offset);
|
||||
setProp('extraSubtitlesOffset', offset);
|
||||
};
|
||||
|
||||
const setSubtitlesTextColor = (color) => {
|
||||
setProp('subtitlesTextColor', color);
|
||||
setProp('extraSubtitlesTextColor', color);
|
||||
};
|
||||
|
||||
const setSubtitlesBackgroundColor = (color) => {
|
||||
setProp('subtitlesBackgroundColor', color);
|
||||
setProp('extraSubtitlesBackgroundColor', color);
|
||||
};
|
||||
|
||||
const setSubtitlesOutlineColor = (color) => {
|
||||
setProp('subtitlesOutlineColor', color);
|
||||
setProp('extraSubtitlesOutlineColor', color);
|
||||
};
|
||||
|
||||
const onError = (error) => {
|
||||
events.emit('error', error);
|
||||
};
|
||||
|
|
@ -152,7 +205,15 @@ const useVideo = () => {
|
|||
video.current.on('extraSubtitlesTrackLoaded', onExtraSubtitlesTrackLoaded);
|
||||
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 {
|
||||
|
|
@ -163,8 +224,19 @@ const useVideo = () => {
|
|||
unload,
|
||||
addExtraSubtitlesTracks,
|
||||
addLocalSubtitles,
|
||||
setProp,
|
||||
setPaused,
|
||||
setVolume,
|
||||
setMuted,
|
||||
setTime,
|
||||
setPlaybackSpeed,
|
||||
setAudioTrack,
|
||||
setSubtitlesTrack,
|
||||
setSubtitlesDelay,
|
||||
setSubtitlesSize,
|
||||
setSubtitlesOffset,
|
||||
setSubtitlesTextColor,
|
||||
setSubtitlesBackgroundColor,
|
||||
setSubtitlesOutlineColor,
|
||||
setExtraSubtitlesTrack,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ const Search = ({ queryParams }) => {
|
|||
<div className={styles['message-container']}>
|
||||
<Image
|
||||
className={styles['image']}
|
||||
src={require('/images/empty.png')}
|
||||
src={require('/assets/images/empty.png')}
|
||||
alt={' '}
|
||||
/>
|
||||
<div className={styles['message-label']}>{ t.string('STREMIO_TV_SEARCH_NO_ADDONS') }</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import React, { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, MultiselectMenu, Toggle } from 'stremio/components';
|
||||
import { Button } from 'stremio/components';
|
||||
import { useServices } from 'stremio/services';
|
||||
import { usePlatform, useToast } from 'stremio/common';
|
||||
import { Section, Option, Link } from '../components';
|
||||
import User from './User';
|
||||
import useDataExport from './useDataExport';
|
||||
import styles from './General.less';
|
||||
import useGeneralOptions from './useGeneralOptions';
|
||||
|
||||
type Props = {
|
||||
profile: Profile,
|
||||
|
|
@ -15,18 +14,11 @@ type Props = {
|
|||
|
||||
const General = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { core, shell } = useServices();
|
||||
const { core } = useServices();
|
||||
const platform = usePlatform();
|
||||
const toast = useToast();
|
||||
const [dataExport, loadDataExport] = useDataExport();
|
||||
|
||||
const {
|
||||
interfaceLanguageSelect,
|
||||
quitOnCloseToggle,
|
||||
escExitFullscreenToggle,
|
||||
hideSpoilersToggle,
|
||||
} = useGeneralOptions(profile);
|
||||
|
||||
const [traktAuthStarted, setTraktAuthStarted] = useState(false);
|
||||
|
||||
const isTraktAuthenticated = useMemo(() => {
|
||||
|
|
@ -143,39 +135,6 @@ const General = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
|
|||
</Button>
|
||||
</Option>
|
||||
</Section>
|
||||
|
||||
<Section>
|
||||
<Option label={'SETTINGS_UI_LANGUAGE'}>
|
||||
<MultiselectMenu
|
||||
className={'multiselect'}
|
||||
{...interfaceLanguageSelect}
|
||||
/>
|
||||
</Option>
|
||||
{
|
||||
shell.active &&
|
||||
<Option label={'SETTINGS_QUIT_ON_CLOSE'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...quitOnCloseToggle}
|
||||
/>
|
||||
</Option>
|
||||
}
|
||||
{
|
||||
shell.active &&
|
||||
<Option label={'SETTINGS_FULLSCREEN_EXIT'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...escExitFullscreenToggle}
|
||||
/>
|
||||
</Option>
|
||||
}
|
||||
<Option label={'SETTINGS_BLUR_UNWATCHED_IMAGE'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...hideSpoilersToggle}
|
||||
/>
|
||||
</Option>
|
||||
</Section>
|
||||
</>;
|
||||
});
|
||||
|
||||
|
|
|
|||