Merge branch 'development' into feat/details-scroll-to-last-watched-video

This commit is contained in:
Timothy Z. 2025-10-23 17:05:46 +03:00
commit 16877fa4bf
66 changed files with 11931 additions and 15836 deletions

View file

@ -13,8 +13,8 @@ jobs:
steps: steps:
# Auto assign PR to author # Auto assign PR to author
- name: Auto Assign PR to Author - name: Auto Assign PR to Author
if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]' if: github.event.pull_request.head.repo.fork == false && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
uses: actions/github-script@v7 uses: actions/github-script@v8
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |
@ -31,10 +31,10 @@ jobs:
# Dynamic labeling based on PR/Issue title # Dynamic labeling based on PR/Issue title
- name: Label PRs and Issues - name: Label PRs and Issues
if: github.actor != 'dependabot[bot]' if: github.event.pull_request.head.repo.fork == false && github.actor != 'dependabot[bot]'
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: actions/github-script@v7 uses: actions/github-script@v8
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |

View file

@ -5,7 +5,7 @@ on:
branches: branches:
- development - development
tags-ignore: tags-ignore:
- '**' - "**"
pull_request: pull_request:
branches: branches:
- development - development
@ -20,20 +20,26 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
- name: Setup node - name: Setup node
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version-file: .nvmrc node-version-file: .nvmrc
cache: "pnpm"
- name: Install NPM dependencies - name: Install NPM dependencies
run: npm ci run: pnpm install
- name: Build - name: Build
run: npm run build run: pnpm build
- name: Test - name: Test
run: npm test run: pnpm test
- name: Lint - name: Lint
run: npm run lint run: pnpm lint
# Create recursivelly the destiantion dir with # Create recursively the destination dir with
# "--parrents where no error if existing, make parent directories as needed." # "--parrents where no error if existing, make parent directories as needed."
- run: mkdir -p ./build/${{ github.head_ref || github.ref_name }} - run: mkdir -p ./build/${{ github.head_ref || github.ref_name }}
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages

View file

@ -9,22 +9,39 @@ permissions:
contents: write contents: write
jobs: jobs:
build: cleanup:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
ref: gh-pages ref: gh-pages
fetch-depth: 0 fetch-depth: 0
- name: Delete directories older than 1 year - name: Delete directories that don't have existing branch
run: | run: |
for dir in $(find . -mindepth 1 -maxdepth 2 -type d -not -path '*/\.*'); do branches=( $(git branch -r | grep origin | grep -v HEAD | sed 's|origin/||') )
if ! git log -1 --since="1 year ago" -- "$dir" | grep -q .; then declare -p branches
echo "Deleting $dir"
rm -rf "$dir" find . -mindepth 1 -maxdepth 2 -type d -not -path '*/\.*' | while read -r dir; do
fi path="${dir#./}"
if [[ " ${branches[*]} " =~ " $path " ]]; then
continue
fi
keep_parent=false
for branch in "${branches[@]}"; do
if [[ "$branch" == "$path/"* ]]; then
keep_parent=true
break
fi
done
if ! $keep_parent; then
echo "Deleting $dir"
rm -rf "$dir"
fi
done done
- name: Commit and push - name: Commit and push

View file

@ -9,20 +9,20 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Install NPM dependencies - name: Install NPM dependencies
run: npm install run: pnpm install
- name: Build - name: Build
env: env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }} SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
run: npm run build run: pnpm build
- name: Zip build artifact - name: Zip build artifact
run: zip -r stremio-web.zip ./build run: zip -r stremio-web.zip ./build
- name: Upload build artifact to GitHub release assets - name: Upload build artifact to GitHub release assets
uses: svenstaro/upload-release-action@2.11.1 uses: svenstaro/upload-release-action@2.11.2
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }}
file: stremio-web.zip file: stremio-web.zip
asset_name: stremio-web.zip asset_name: stremio-web.zip
tag: ${{ github.ref }} tag: ${{ github.ref }}
overwrite: true overwrite: true

View file

@ -2,35 +2,42 @@
## Our Pledge ## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and community a harassment-free experience for everyone, regardless of age, level of experience, education, nationality or race. We as contributors and maintainers want to make contributing to our project and community a nice experience for everyone.
## Our Standards ## Our Standards
Examples of behavior that contributes to creating a positive environment include: Examples of positive behavior:
- Using welcoming and inclusive language. - Using welcoming language.
- Being respectful of differing viewpoints and experiences. - Being respectful.
- Accepting constructive criticism. - Accepting constructive criticism.
- Focusing on what is best for the community.
- Showing empathy towards other community members.
Examples of unacceptable behavior by participants include: Examples of bad behavior:
- The use of sexualized language or imagery and unwelcome sexual attention or advances. - Use of sexualized language.
- Trolling, insulting/derogatory comments, and personal or political attacks. - Trolling, insulting comments, and personal or political attacks.
- Public or private harassment. - Public or private harassment.
- Publishing others private information, such as a physical or electronic address, without explicit permission. - Publishing others private information, such as a physical or electronic address, without explicit permission.
- Other conduct which could reasonably be considered inappropriate in a professional setting. - Submitting entirely generated by AI PRs with agents such as Devin, Claude Code, Cursor Agent etc.
- Submitting PRs which in majority contain only AI generated code (including docs & comments) and do not solve an actual issue.
- Spamming issues because of no ETAs on issues.
## Our Responsibilities ## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers are responsible for enforcing this code of conduct. They can remove or edit comments, code, and other contributions that don't follow these rules. They can also ban users who behave inappropriately.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, pull requests, and other contributions that do not align with this Code of Conduct, as well as to temporarily or permanently ban any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Suggestions for newbies
- Contributors are welcomed to use AI models as "help" in solving issues, but you must always double check the code that you're submitting.
- Refrain from excessive comments generated by AI.
- Refrain from docs generated entirely by AI.
- Always check what files you are committing and submitting to the PR when you are using any agent for help or an AI model.
- If you don't know how to tackle a problem and AI can't help you, please just ask or look in Stack Overlflow, Google, Medium etc.
- Learning how to code is fun and easier when using AI, but sometimes it might be just too much ... what are you going to learn, if AI does everything for you and you don't know what the code you are submitting actually does?!
## Scope ## Scope
This Code of Conduct applies within all `stremio-web` spaces, and also applies when an individual is officially representing the project or its community in public spaces. This Code of Conduct applies everywhere in `stremio-web` repository, and also applies when an individual is officially representing the project or its community in other spaces.
## Enforcement ## Enforcement

View file

@ -1,6 +1,6 @@
# Stremio - Freedom to Stream # Stremio - Freedom to Stream
![Build](https://github.com/stremio/stremio-web/workflows/Build/badge.svg?branch=development) [![Build](https://github.com/Stremio/stremio-web/actions/workflows/build.yml/badge.svg)](https://github.com/Stremio/stremio-web/actions/workflows/build.yml)
[![Github Page](https://img.shields.io/website?label=Page&logo=github&up_message=online&down_message=offline&url=https%3A%2F%2Fstremio.github.io%2Fstremio-web%2F)](https://stremio.github.io/stremio-web/development) [![Github Page](https://img.shields.io/website?label=Page&logo=github&up_message=online&down_message=offline&url=https%3A%2F%2Fstremio.github.io%2Fstremio-web%2F)](https://stremio.github.io/stremio-web/development)
Stremio is a modern media center that's a one-stop solution for your video entertainment. You discover, watch and organize video content from easy to install addons. Stremio is a modern media center that's a one-stop solution for your video entertainment. You discover, watch and organize video content from easy to install addons.
@ -10,24 +10,31 @@ Stremio is a modern media center that's a one-stop solution for your video enter
### Prerequisites ### Prerequisites
* Node.js 12 or higher * Node.js 12 or higher
* npm 6 or higher * [pnpm](https://pnpm.io/installation) 10 or higher
### Install dependencies ### Install dependencies
```bash ```bash
npm install pnpm install
``` ```
### Start development server ### Start development server
```bash ```bash
npm start pnpm start
``` ```
### Production build ### Production build
```bash ```bash
npm run build pnpm run build
```
### Run with Docker
```bash
docker build -t stremio-web .
docker run -p 8080:8080 stremio-web
``` ```
## Screenshots ## Screenshots

View file

@ -82,7 +82,7 @@ export default [
'@stylistic/semi-spacing': 'error', '@stylistic/semi-spacing': 'error',
'@stylistic/space-before-blocks': 'error', '@stylistic/space-before-blocks': 'error',
'@stylistic/no-trailing-spaces': 'error', '@stylistic/no-trailing-spaces': 'error',
'@stylistic/func-call-spacing': 'error', '@stylistic/function-call-spacing': 'error',
'@stylistic/semi': 'error', '@stylistic/semi': 'error',
'@stylistic/no-extra-semi': 'error', '@stylistic/no-extra-semi': 'error',
'@stylistic/eol-last': 'error', '@stylistic/eol-last': 'error',

15562
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{ {
"name": "stremio", "name": "stremio",
"displayName": "Stremio", "displayName": "Stremio",
"version": "5.0.0-beta.26", "version": "5.0.0-beta.27",
"author": "Smart Code OOD", "author": "Smart Code OOD",
"private": true, "private": true,
"license": "gpl-2.0", "license": "gpl-2.0",
@ -11,15 +11,15 @@
"build": "webpack --mode production", "build": "webpack --mode production",
"test": "jest", "test": "jest",
"lint": "eslint src", "lint": "eslint src",
"scan-translations": "npx jest ./tests/i18nScan.test.js" "scan-translations": "pnpx jest ./tests/i18nScan.test.js"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "7.26.0", "@babel/runtime": "7.26.0",
"@sentry/browser": "8.42.0", "@sentry/browser": "8.42.0",
"@stremio/stremio-colors": "5.2.0", "@stremio/stremio-colors": "5.2.0",
"@stremio/stremio-core-web": "0.49.4", "@stremio/stremio-core-web": "0.50.0",
"@stremio/stremio-icons": "5.7.1", "@stremio/stremio-icons": "5.7.1",
"@stremio/stremio-video": "0.0.61", "@stremio/stremio-video": "0.0.64",
"a-color-picker": "1.2.1", "a-color-picker": "1.2.1",
"bowser": "2.11.0", "bowser": "2.11.0",
"buffer": "6.0.3", "buffer": "6.0.3",
@ -41,7 +41,7 @@
"react-i18next": "^15.1.3", "react-i18next": "^15.1.3",
"react-is": "18.3.1", "react-is": "18.3.1",
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6", "spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
"stremio-translations": "github:Stremio/stremio-translations#abe7684165a031755e9aee39da26daa806ba7824", "stremio-translations": "github:Stremio/stremio-translations#01aaa201e419782b26b9f2cbe4430795021426e5",
"url": "0.11.4", "url": "0.11.4",
"use-long-press": "^3.2.0" "use-long-press": "^3.2.0"
}, },
@ -50,8 +50,8 @@
"@babel/preset-env": "7.26.0", "@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.26.3", "@babel/preset-react": "7.26.3",
"@eslint/js": "^9.16.0", "@eslint/js": "^9.16.0",
"@stylistic/eslint-plugin": "^2.11.0", "@stylistic/eslint-plugin": "^5.4.0",
"@stylistic/eslint-plugin-jsx": "^2.11.0", "@stylistic/eslint-plugin-jsx": "^4.4.1",
"@types/hat": "^0.0.4", "@types/hat": "^0.0.4",
"@types/lodash.isequal": "^4.5.8", "@types/lodash.isequal": "^4.5.8",
"@types/lodash.throttle": "^4.1.9", "@types/lodash.throttle": "^4.1.9",

11030
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 947 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View file

@ -6,11 +6,12 @@ const { useTranslation } = require('react-i18next');
const { Router } = require('stremio-router'); const { Router } = require('stremio-router');
const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services'); const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services');
const { NotFound } = require('stremio/routes'); const { NotFound } = require('stremio/routes');
const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, CONSTANTS, withCoreSuspender, useShell } = require('stremio/common'); const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, ShortcutsProvider, CONSTANTS, withCoreSuspender, useShell, useBinaryState } = require('stremio/common');
const ServicesToaster = require('./ServicesToaster'); const ServicesToaster = require('./ServicesToaster');
const DeepLinkHandler = require('./DeepLinkHandler'); const DeepLinkHandler = require('./DeepLinkHandler');
const SearchParamsHandler = require('./SearchParamsHandler'); const SearchParamsHandler = require('./SearchParamsHandler');
const { default: UpdaterBanner } = require('./UpdaterBanner'); const { default: UpdaterBanner } = require('./UpdaterBanner');
const { default: ShortcutsModal } = require('./ShortcutsModal');
const ErrorDialog = require('./ErrorDialog'); const ErrorDialog = require('./ErrorDialog');
const withProtectedRoutes = require('./withProtectedRoutes'); const withProtectedRoutes = require('./withProtectedRoutes');
const routerViewsConfig = require('./routerViewsConfig'); const routerViewsConfig = require('./routerViewsConfig');
@ -38,6 +39,14 @@ const App = () => {
}; };
}, []); }, []);
const [initialized, setInitialized] = React.useState(false); const [initialized, setInitialized] = React.useState(false);
const [shortcutModalOpen,, closeShortcutsModal, toggleShortcutModal] = useBinaryState(false);
const onShortcut = React.useCallback((name) => {
if (name === 'shortcuts') {
toggleShortcutModal();
}
}, [toggleShortcutModal]);
React.useEffect(() => { React.useEffect(() => {
let prevPath = window.location.hash.slice(1); let prevPath = window.location.hash.slice(1);
const onLocationHashChange = () => { const onLocationHashChange = () => {
@ -159,7 +168,8 @@ const App = () => {
services.core.transport.dispatch({ services.core.transport.dispatch({
action: 'Ctx', action: 'Ctx',
args: { args: {
action: 'PullUserFromAPI' action: 'PullUserFromAPI',
args: {}
} }
}); });
services.core.transport.dispatch({ services.core.transport.dispatch({
@ -203,15 +213,20 @@ const App = () => {
<ToastProvider className={styles['toasts-container']}> <ToastProvider className={styles['toasts-container']}>
<TooltipProvider className={styles['tooltip-container']}> <TooltipProvider className={styles['tooltip-container']}>
<FileDropProvider className={styles['file-drop-container']}> <FileDropProvider className={styles['file-drop-container']}>
<ServicesToaster /> <ShortcutsProvider onShortcut={onShortcut}>
<DeepLinkHandler /> {
<SearchParamsHandler /> shortcutModalOpen && <ShortcutsModal onClose={closeShortcutsModal}/>
<UpdaterBanner className={styles['updater-banner-container']} /> }
<RouterWithProtectedRoutes <ServicesToaster />
className={styles['router']} <DeepLinkHandler />
viewsConfig={routerViewsConfig} <SearchParamsHandler />
onPathNotMatch={onPathNotMatch} <UpdaterBanner className={styles['updater-banner-container']} />
/> <RouterWithProtectedRoutes
className={styles['router']}
viewsConfig={routerViewsConfig}
onPathNotMatch={onPathNotMatch}
/>
</ShortcutsProvider>
</FileDropProvider> </FileDropProvider>
</TooltipProvider> </TooltipProvider>
</ToastProvider> </ToastProvider>

View file

@ -36,7 +36,13 @@ const SearchParamsHandler = () => {
}, },
}, },
}); });
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'AddServerUrl',
args: streamingServerUrl,
},
});
toast.show({ toast.show({
type: 'success', type: 'success',
title: `Using streaming server at ${streamingServerUrl}`, title: `Using streaming server at ${streamingServerUrl}`,

View 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;

View file

@ -0,0 +1,2 @@
import ShortcutsModal from './ShortcutsModal';
export default ShortcutsModal;

View 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;
}
}
}

View file

@ -35,7 +35,7 @@
@top-overlay-size: 5.25rem; @top-overlay-size: 5.25rem;
@bottom-overlay-size: 0rem; @bottom-overlay-size: 0rem;
@overlap-size: 3rem; @overlap-size: 3rem;
@transparency-grandient-pad: 6rem; @transparency-gradient-pad: 6rem;
:root { :root {
--landscape-shape-ratio: 0.5625; --landscape-shape-ratio: 0.5625;
@ -69,7 +69,7 @@
--top-overlay-size: @top-overlay-size; --top-overlay-size: @top-overlay-size;
--bottom-overlay-size: @bottom-overlay-size; --bottom-overlay-size: @bottom-overlay-size;
--overlap-size: @overlap-size; --overlap-size: @overlap-size;
--transparency-grandient-pad: @transparency-grandient-pad; --transparency-gradient-pad: @transparency-gradient-pad;
--safe-area-inset-top: @safe-area-inset-top; --safe-area-inset-top: @safe-area-inset-top;
--safe-area-inset-right: @safe-area-inset-right; --safe-area-inset-right: @safe-area-inset-right;
--safe-area-inset-bottom: @safe-area-inset-bottom; --safe-area-inset-bottom: @safe-area-inset-bottom;

View file

@ -42,7 +42,7 @@ const FileDropProvider = ({ className, children }: Props) => {
.then((buffer) => { .then((buffer) => {
listeners listeners
.filter(([type]) => file.type ? type === file.type : isFileType(buffer, type)) .filter(([type]) => file.type ? type === file.type : isFileType(buffer, type))
.forEach(([, listerner]) => listerner(file.name, buffer)); .forEach(([, listener]) => listener(file.name, buffer));
}); });
} }

View file

@ -0,0 +1,54 @@
import React, { createContext, useCallback, useContext, useEffect } from 'react';
import shortcuts from './shortcuts.json';
const SHORTCUTS = shortcuts.map(({ shortcuts }) => shortcuts).flat();
export type ShortcutName = string;
export type ShortcutListener = () => void;
interface ShortcutsContext {
grouped: ShortcutGroup[],
}
const ShortcutsContext = createContext<ShortcutsContext>({} as ShortcutsContext);
type Props = {
children: JSX.Element,
onShortcut: (name: ShortcutName) => void,
};
const ShortcutsProvider = ({ children, onShortcut }: Props) => {
const onKeyDown = useCallback(({ ctrlKey, shiftKey, key }: KeyboardEvent) => {
SHORTCUTS.forEach(({ name, combos }) => combos.forEach((keys) => {
const modifers = (keys.includes('Ctrl') ? ctrlKey : true)
&& (keys.includes('Shift') ? shiftKey : true);
if (modifers && keys.includes(key.toUpperCase())) {
onShortcut(name as ShortcutName);
}
}));
}, [onShortcut]);
useEffect(() => {
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, [onKeyDown]);
return (
<ShortcutsContext.Provider value={{ grouped: shortcuts }}>
{children}
</ShortcutsContext.Provider>
);
};
const useShortcuts = () => {
return useContext(ShortcutsContext);
};
export {
ShortcutsProvider,
useShortcuts
};

View file

@ -0,0 +1,5 @@
import { ShortcutsProvider, useShortcuts } from './Shortcuts';
export {
ShortcutsProvider,
useShortcuts,
};

View file

@ -0,0 +1,89 @@
[
{
"name": "general",
"label": "SETTINGS_NAV_GENERAL",
"shortcuts": [
{
"name": "navigateTabs",
"label": "SETTINGS_SHORTCUT_NAVIGATE_MENUS",
"combos": [["1", "2", "3", "4", "5", "6"]]
},
{
"name": "navigateSearch",
"label": "SETTINGS_SHORTCUT_GO_TO_SEARCH",
"combos": [["0"]]
},
{
"name": "fullscreen",
"label": "SETTINGS_SHORTCUT_FULLSCREEN",
"combos": [["F"]]
},
{
"name": "exit",
"label": "SETTINGS_SHORTCUT_EXIT_BACK",
"combos": [["Escape"]]
},
{
"name": "shortcuts",
"label": "SETTINGS_SHORTCUT_SHORTCUTS",
"combos": [["Ctrl", "/"]]
}
]
},
{
"name": "player",
"label": "SETTINGS_NAV_PLAYER",
"shortcuts": [
{
"name": "playPause",
"label": "SETTINGS_SHORTCUT_PLAY_PAUSE",
"combos": [["Space"]]
},
{
"name": "seekForward",
"label": "SETTINGS_SHORTCUT_SEEK_FORWARD",
"combos": [["ArrowRight"], ["Shift", "ArrowRight"]]
},
{
"name": "seekBackward",
"label": "SETTINGS_SHORTCUT_SEEK_BACKWARD",
"combos": [["ArrowLeft"], ["Shift", "ArrowLeft"]]
},
{
"name": "volumeUp",
"label": "SETTINGS_SHORTCUT_VOLUME_UP",
"combos": [["ArrowUp"]]
},
{
"name": "volumeDown",
"label": "SETTINGS_SHORTCUT_VOLUME_DOWN",
"combos": [["ArrowDown"]]
},
{
"name": "subtitlesSize",
"label": "SETTINGS_SHORTCUT_SUBTITLES_SIZE",
"combos": [["-"], ["="]]
},
{
"name": "subtitlesDelay",
"label": "SETTINGS_SHORTCUT_SUBTITLES_DELAY",
"combos": [["G"], ["H"]]
},
{
"name": "subtitlesMenu",
"label": "SETTINGS_SHORTCUT_MENU_SUBTITLES",
"combos": [["S"]]
},
{
"name": "audioMenu",
"label": "SETTINGS_SHORTCUT_MENU_AUDIO",
"combos": [["A"]]
},
{
"name": "infoMenu",
"label": "SETTINGS_SHORTCUT_MENU_INFO",
"combos": [["I"]]
}
]
}
]

11
src/common/Shortcuts/types.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
type Shortcut = {
name: string,
label: string,
combos: string[][],
};
type ShortcutGroup = {
name: string,
label: string,
shortcuts: Shortcut[],
};

View file

@ -4,6 +4,7 @@ const { FileDropProvider, onFileDrop } = require('./FileDrop');
const { PlatformProvider, usePlatform } = require('./Platform'); const { PlatformProvider, usePlatform } = require('./Platform');
const { ToastProvider, useToast } = require('./Toast'); const { ToastProvider, useToast } = require('./Toast');
const { TooltipProvider, Tooltip } = require('./Tooltips'); const { TooltipProvider, Tooltip } = require('./Tooltips');
const { ShortcutsProvider, useShortcuts } = require('./Shortcuts');
const comparatorWithPriorities = require('./comparatorWithPriorities'); const comparatorWithPriorities = require('./comparatorWithPriorities');
const CONSTANTS = require('./CONSTANTS'); const CONSTANTS = require('./CONSTANTS');
const { withCoreSuspender, useCoreSuspender } = require('./CoreSuspender'); const { withCoreSuspender, useCoreSuspender } = require('./CoreSuspender');
@ -35,6 +36,8 @@ module.exports = {
onFileDrop, onFileDrop,
PlatformProvider, PlatformProvider,
usePlatform, usePlatform,
ShortcutsProvider,
useShortcuts,
ToastProvider, ToastProvider,
useToast, useToast,
TooltipProvider, TooltipProvider,

View file

@ -10,11 +10,15 @@ const useFullscreen = () => {
const [fullscreen, setFullscreen] = useState(false); const [fullscreen, setFullscreen] = useState(false);
const requestFullscreen = useCallback(() => { const requestFullscreen = useCallback(async () => {
if (shell.active) { if (shell.active) {
shell.send('win-set-visibility', { fullscreen: true }); shell.send('win-set-visibility', { fullscreen: true });
} else { } else {
document.documentElement.requestFullscreen(); try {
await document.documentElement.requestFullscreen();
} catch (err) {
console.error('Error enabling fullscreen', err);
}
} }
}, []); }, []);

View file

@ -1,2 +1,2 @@
declare const useNotifcations: () => Notifications; declare const useNotifications: () => Notifications;
export = useNotifcations; export = useNotifications;

View file

@ -86,7 +86,7 @@
} }
} }
@media only screen and (min-width: @small) and (orientation: portait) { @media only screen and (min-width: @small) and (orientation: portrait) {
.bottom-sheet { .bottom-sheet {
display: none; display: none;
} }

View file

@ -21,7 +21,7 @@ const ColorPicker = ({ className, value, onInput }) => {
showRGB: false, showRGB: false,
showAlpha: true showAlpha: true
}); });
const pickerClipboard = pickerElementRef.current.querySelector('.a-color-picker-clipbaord'); const pickerClipboard = pickerElementRef.current.querySelector('.a-color-picker-clipboard');
if (pickerClipboard instanceof HTMLElement) { if (pickerClipboard instanceof HTMLElement) {
pickerClipboard.tabIndex = -1; pickerClipboard.tabIndex = -1;
} }

View file

@ -16,7 +16,7 @@
box-shadow: 0 0 .2rem var(--color-surfacedark); box-shadow: 0 0 .2rem var(--color-surfacedark);
} }
:global(.a-color-picker-clipbaord) { :global(.a-color-picker-clipboard) {
pointer-events: none; pointer-events: none;
} }
} }

View file

@ -1,5 +1,5 @@
// Copyright (C) 2017-2023 Smart code 203358507 // Copyright (C) 2017-2023 Smart code 203358507
const ContineWatchingItem = require('./ContinueWatchingItem'); const ContinueWatchingItem = require('./ContinueWatchingItem');
module.exports = ContineWatchingItem; module.exports = ContinueWatchingItem;

View file

@ -65,8 +65,16 @@
padding: 0 1rem; padding: 0 1rem;
.icon-container { .icon-container {
height: 2rem;
width: 2rem; width: 2rem;
.icon {
width: 2rem;
height: 2rem;
}
}
.label-container {
display: none;
} }
} }
} }

View file

@ -240,6 +240,10 @@
border-radius: 2rem; border-radius: 2rem;
} }
} }
.ratings {
margin-right: 0;
}
} }
} }

View 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;
}
}
}

View 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;

View 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);
}

View 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;

View file

@ -0,0 +1,2 @@
import Keys from './Keys';
export default Keys;

View file

@ -0,0 +1,2 @@
import Combos from './Combos';
export default Combos;

View file

@ -0,0 +1,44 @@
.shortcuts-group {
flex: 1 1 0;
position: relative;
min-width: 30rem;
display: flex;
flex-direction: column;
gap: 2rem;
overflow: visible;
.title {
flex: none;
display: flex;
font-size: 1rem;
font-weight: 400;
color: var(--primary-foreground-color);
opacity: 0.6;
}
.shortcuts {
position: relative;
display: flex;
flex-direction: column;
gap: 2rem;
overflow: visible;
.shortcut {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 2rem;
overflow: visible;
.label {
position: relative;
font-size: 1rem;
color: var(--primary-foreground-color);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
}
}

View 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;

View file

@ -0,0 +1,2 @@
import ShortcutsGroup from './ShortcutsGroup';
export default ShortcutsGroup;

View file

@ -25,6 +25,7 @@ import RadioButton from './RadioButton';
import SearchBar from './SearchBar'; import SearchBar from './SearchBar';
import SharePrompt from './SharePrompt'; import SharePrompt from './SharePrompt';
import Slider from './Slider'; import Slider from './Slider';
import ShortcutsGroup from './ShortcutsGroup';
import TextInput from './TextInput'; import TextInput from './TextInput';
import Toggle from './Toggle'; import Toggle from './Toggle';
import Transition from './Transition'; import Transition from './Transition';
@ -59,6 +60,7 @@ export {
SearchBar, SearchBar,
SharePrompt, SharePrompt,
Slider, Slider,
ShortcutsGroup,
TextInput, TextInput,
Toggle, Toggle,
Transition, Transition,

View file

@ -36,7 +36,7 @@ i18n
const root = ReactDOM.createRoot(document.getElementById('app')); const root = ReactDOM.createRoot(document.getElementById('app'));
root.render(<App />); root.render(<App />);
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { if (process.env.NODE_ENV === 'production' && process.env.SERVICE_WORKER_DISABLED !== 'true' && process.env.SERVICE_WORKER_DISABLED !== true && 'serviceWorker' in navigator) {
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('service-worker.js') navigator.serviceWorker.register('service-worker.js')
.catch((registrationError) => { .catch((registrationError) => {

View file

@ -2,6 +2,25 @@
@import (reference) '~stremio/common/screen-sizes.less'; @import (reference) '~stremio/common/screen-sizes.less';
.disable-cell-items() {
.cell {
.items {
.item {
pointer-events: none;
}
}
}
}
.compact-items() {
.cell {
.items {
padding: 1px;
gap: 0.15rem;
}
}
}
.cell { .cell {
position: relative; position: relative;
display: flex; display: flex;
@ -27,12 +46,9 @@
} }
.heading { .heading {
flex: none;
position: relative; position: relative;
height: 3rem;
display: flex; display: flex;
align-items: center; align-items: flex-start;
padding: 0 1rem;
.day { .day {
flex: none; flex: none;
@ -50,12 +66,15 @@
} }
.items { .items {
flex: 0 1 10rem;
position: relative; position: relative;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 1rem; gap: 0.2rem;
padding: 0 0.5rem 0.5rem 0.5rem; padding: 0.1rem;
flex: 1 1 60%;
overflow-x: auto;
overflow-y: hidden;
min-width: 0;
.item { .item {
flex: none; flex: none;
@ -64,7 +83,9 @@
justify-content: center; justify-content: center;
height: 100%; height: 100%;
aspect-ratio: 2 / 3; aspect-ratio: 2 / 3;
border-radius: var(--border-radius); border-radius: calc(var(--border-radius) / 2);
max-height: 100%;
max-width: 100%;
.icon { .icon {
flex: none; flex: none;
@ -80,13 +101,11 @@
} }
.poster { .poster {
flex: auto; height: auto;
z-index: 0; max-height: 100%;
position: relative; aspect-ratio: 2 / 3;
height: 100%;
width: 100%;
object-fit: cover; object-fit: cover;
opacity: 1; border-radius: inherit
} }
.icon, .poster { .icon, .poster {
@ -117,8 +136,11 @@
&.today { &.today {
.heading { .heading {
padding: 0.3rem;
.day { .day {
background-color: var(--primary-accent-color); background-color: var(--primary-accent-color);
height: 1.5rem;
width: 1.5rem;
} }
} }
} }
@ -134,56 +156,55 @@
} }
} }
@media only screen and (max-height: @minimum) and (orientation: portrait) { @media only screen and (max-width: @minimum) {
.cell { .disable-cell-items();
.heading {
justify-content: center;
}
.items {
display: none;
}
.more {
display: flex;
}
}
} }
@media only screen and (max-height: @xxsmall) and (orientation: landscape) { @media @phone-portrait {
.cell {
flex-direction: column;
display: grid;
}
.compact-items();
.disable-cell-items();
}
@media @phone-landscape {
.cell { .cell {
flex-direction: row; flex-direction: row;
align-items: center;
.items {
display: none;
}
.more {
display: flex;
}
} }
.compact-items();
.disable-cell-items();
} }
@media only screen and (max-height: @xsmall) and (max-width: @xsmall) { @media only screen and (max-height: @medium) and (max-width: @medium) and (orientation: landscape) {
.cell { .cell {
gap: 0; gap: 0;
.heading { .heading {
height: 2rem;
.day { .day {
padding: 0;
font-size: 0.875rem; font-size: 0.875rem;
} }
} }
.items { .items {
padding: 0.25rem; width: 100%;
padding-left: 0.5rem;
.item {
pointer-events: none;
border-radius: calc(var(--border-radius) / 2);
}
} }
} }
} }
@media only screen and (max-width: @minimum) and (orientation: portrait) and (pointer: fine) {
.cell {
display: flex;
.heading {
flex: 1 1 33%;
}
}
}
@media screen and (max-width: @small) and (orientation: portrait) {
.disable-cell-items();
}

View file

@ -45,6 +45,7 @@
display: grid; display: grid;
grid-template-columns: repeat(7, 1fr); grid-template-columns: repeat(7, 1fr);
gap: 1px; gap: 1px;
grid-auto-rows: 1fr;
} }
} }

View file

@ -138,11 +138,11 @@ const Intro = ({ queryParams }) => {
}, []); }, []);
const loginWithEmail = React.useCallback(() => { const loginWithEmail = React.useCallback(() => {
if (typeof state.email !== 'string' || state.email.length === 0 || !emailRef.current.validity.valid) { if (typeof state.email !== 'string' || state.email.length === 0 || !emailRef.current.validity.valid) {
dispatch({ type: 'error', error: 'Invalid email' }); dispatch({ type: 'error', error: t('INVALID_EMAIL') });
return; return;
} }
if (typeof state.password !== 'string' || state.password.length === 0) { if (typeof state.password !== 'string' || state.password.length === 0) {
dispatch({ type: 'error', error: 'Invalid password' }); dispatch({ type: 'error', error: t('INVALID_PASSWORD') });
return; return;
} }
openLoaderModal(); openLoaderModal();
@ -160,26 +160,26 @@ const Intro = ({ queryParams }) => {
}, [state.email, state.password]); }, [state.email, state.password]);
const loginAsGuest = React.useCallback(() => { const loginAsGuest = React.useCallback(() => {
if (!state.termsAccepted) { if (!state.termsAccepted) {
dispatch({ type: 'error', error: 'You must accept the Terms of Service' }); dispatch({ type: 'error', error: t('MUST_ACCEPT_TERMS') });
return; return;
} }
window.location = '#/'; window.location = '#/';
}, [state.termsAccepted]); }, [state.termsAccepted]);
const signup = React.useCallback(() => { const signup = React.useCallback(() => {
if (typeof state.email !== 'string' || state.email.length === 0 || !emailRef.current.validity.valid) { if (typeof state.email !== 'string' || state.email.length === 0 || !emailRef.current.validity.valid) {
dispatch({ type: 'error', error: 'Invalid email' }); dispatch({ type: 'error', error: t('INVALID_EMAIL') });
return; return;
} }
if (typeof state.password !== 'string' || state.password.length === 0) { if (typeof state.password !== 'string' || state.password.length === 0) {
dispatch({ type: 'error', error: 'Invalid password' }); dispatch({ type: 'error', error: t('INVALID_PASSWORD') });
return; return;
} }
if (state.password !== state.confirmPassword) { if (state.password !== state.confirmPassword) {
dispatch({ type: 'error', error: 'Passwords do not match' }); dispatch({ type: 'error', error: t('PASSWORDS_NOMATCH') });
return; return;
} }
if (!state.termsAccepted) { if (!state.termsAccepted) {
dispatch({ type: 'error', error: 'You must accept the Terms of Service' }); dispatch({ type: 'error', error: t('MUST_ACCEPT_TERMS') });
return; return;
} }
if (!state.privacyPolicyAccepted) { if (!state.privacyPolicyAccepted) {
@ -387,7 +387,7 @@ const Intro = ({ queryParams }) => {
{ {
state.form === SIGNUP_FORM ? state.form === SIGNUP_FORM ?
<Button className={classnames(styles['form-button'], styles['login-form-button'])} onClick={switchFormOnClick}> <Button className={classnames(styles['form-button'], styles['login-form-button'])} onClick={switchFormOnClick}>
<div className={classnames(styles['label'], styles['uppercase'])}>{t('LOG_IN')}</div> <div className={styles['label']}>{t('LOG_IN')}</div>
</Button> </Button>
: :
null null
@ -395,7 +395,7 @@ const Intro = ({ queryParams }) => {
{ {
state.form === LOGIN_FORM ? state.form === LOGIN_FORM ?
<Button className={classnames(styles['form-button'], styles['signup-form-button'])} onClick={switchFormOnClick}> <Button className={classnames(styles['form-button'], styles['signup-form-button'])} onClick={switchFormOnClick}>
<div className={classnames(styles['label'], styles['uppercase'])}>{t('SIGN_UP_EMAIL')}</div> <div className={styles['label']}>{t('SIGN_UP_EMAIL')}</div>
</Button> </Button>
: :
null null
@ -403,7 +403,7 @@ const Intro = ({ queryParams }) => {
{ {
state.form === SIGNUP_FORM ? state.form === SIGNUP_FORM ?
<Button className={classnames(styles['form-button'], styles['guest-login-button'])} onClick={loginAsGuest}> <Button className={classnames(styles['form-button'], styles['guest-login-button'])} onClick={loginAsGuest}>
<div className={classnames(styles['label'], styles['uppercase'])}>{t('GUEST_LOGIN')}</div> <div className={styles['label']}>{t('GUEST_LOGIN')}</div>
</Button> </Button>
: :
null null

View file

@ -101,10 +101,6 @@
color: var(--primary-foreground-color); color: var(--primary-foreground-color);
text-align: center; text-align: center;
} }
.uppercase {
text-transform: uppercase;
}
} }
.submit-button, .guest-login-button, .signup-form-button, .login-form-button { .submit-button, .guest-login-button, .signup-form-button, .login-form-button {

View file

@ -49,7 +49,7 @@ const useAppleLogin = (): [() => Promise<AppleLoginResponse>, () => void] => {
timeout.current && clearTimeout(timeout.current); timeout.current && clearTimeout(timeout.current);
timeout.current = setTimeout(() => { timeout.current = setTimeout(() => {
if (tries >= MAX_TRIES) if (tries >= MAX_TRIES)
return reject(new Error('Failed to authenticate with Apple')); return reject(new Error('Failed to authenticate with Apple', { cause: 'Number of allowed tries exceeded!' }));
tries++; tries++;

View file

@ -39,7 +39,7 @@ const useFacebookLogin = () => {
timeout.current && clearTimeout(timeout.current); timeout.current && clearTimeout(timeout.current);
timeout.current = setTimeout(() => { timeout.current = setTimeout(() => {
if (tries >= MAX_TRIES) if (tries >= MAX_TRIES)
return reject(new Error('Failed to authenticate with facebook')); return reject(new Error('Failed to authenticate with facebook', { cause: 'Number of allowed tries exceeded!' }));
tries++; tries++;

View file

@ -8,7 +8,7 @@ const langs = require('langs');
const { useTranslation } = require('react-i18next'); const { useTranslation } = require('react-i18next');
const { useRouteFocused } = require('stremio-router'); const { useRouteFocused } = require('stremio-router');
const { useServices } = require('stremio/services'); const { useServices } = require('stremio/services');
const { onFileDrop, useSettings, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS, useShell } = require('stremio/common'); const { onFileDrop, useSettings, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS, useShell, usePlatform } = require('stremio/common');
const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components'); const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components');
const BufferingLoader = require('./BufferingLoader'); const BufferingLoader = require('./BufferingLoader');
const VolumeChangeIndicator = require('./VolumeChangeIndicator'); const VolumeChangeIndicator = require('./VolumeChangeIndicator');
@ -43,6 +43,7 @@ const Player = ({ urlParams, queryParams }) => {
const statistics = useStatistics(player, streamingServer); const statistics = useStatistics(player, streamingServer);
const video = useVideo(); const video = useVideo();
const routeFocused = useRouteFocused(); const routeFocused = useRouteFocused();
const platform = usePlatform();
const toast = useToast(); const toast = useToast();
const [seeking, setSeeking] = React.useState(false); const [seeking, setSeeking] = React.useState(false);
@ -345,6 +346,8 @@ const Player = ({ urlParams, queryParams }) => {
forceTranscoding: forceTranscoding || casting, forceTranscoding: forceTranscoding || casting,
maxAudioChannels: settings.surroundSound ? 32 : 2, maxAudioChannels: settings.surroundSound ? 32 : 2,
hardwareDecoding: settings.hardwareDecoding, hardwareDecoding: settings.hardwareDecoding,
videoMode: settings.videoMode,
platform: platform.name,
streamingServerURL: streamingServer.baseUrl ? streamingServerURL: streamingServer.baseUrl ?
casting ? casting ?
streamingServer.baseUrl streamingServer.baseUrl
@ -532,6 +535,53 @@ const Player = ({ urlParams, queryParams }) => {
} }
}, [settings.pauseOnMinimize, shell.windowClosed, shell.windowHidden]); }, [settings.pauseOnMinimize, shell.windowClosed, shell.windowHidden]);
// Media Session PlaybackState
React.useEffect(() => {
if (!navigator.mediaSession) return;
const playbackState = !video.state.paused ? 'playing' : 'paused';
navigator.mediaSession.playbackState = playbackState;
return () => navigator.mediaSession.playbackState = 'none';
}, [video.state.paused]);
// Media Session Metadata
React.useEffect(() => {
if (!navigator.mediaSession) return;
const metaItem = player.metaItem && player.metaItem.type === 'Ready' ? player.metaItem.content : null;
const videoId = player.selected ? player.selected.streamRequest.path.id : null;
const video = metaItem ? metaItem.videos.find(({ id }) => id === videoId) : null;
const videoInfo = video && video.season && video.episode ? ` (${video.season}x${video.episode})`: null;
const videoTitle = video ? `${video.title}${videoInfo}` : null;
const metaTitle = metaItem ? metaItem.name : null;
const imageUrl = metaItem ? metaItem.logo : null;
const title = videoTitle ?? metaTitle;
const artist = videoTitle ? metaTitle : undefined;
const artwork = imageUrl ? [{ src: imageUrl }] : undefined;
if (title) {
navigator.mediaSession.metadata = new MediaMetadata({
title,
artist,
artwork,
});
}
}, [player.metaItem, player.selected]);
// Media Session Actions
React.useEffect(() => {
if (!navigator.mediaSession) return;
navigator.mediaSession.setActionHandler('play', onPlayRequested);
navigator.mediaSession.setActionHandler('pause', onPauseRequested);
const nexVideoCallback = player.nextVideo ? onNextVideoRequested : null;
navigator.mediaSession.setActionHandler('nexttrack', nexVideoCallback);
}, [player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const onKeyDown = (event) => { const onKeyDown = (event) => {
switch (event.code) { switch (event.code) {

View file

@ -228,7 +228,7 @@ const SubtitlesMenu = React.memo((props) => {
/> />
<Stepper <Stepper
className={styles['stepper']} className={styles['stepper']}
label={'PLAYER_SUBTITLES_VERTICAL_POSIITON'} label={'PLAYER_SUBTITLES_VERTICAL_POSITION'}
value={props.selectedSubtitlesTrackId ? props.subtitlesOffset : props.selectedExtraSubtitlesTrackId ? props.extraSubtitlesOffset : null} value={props.selectedSubtitlesTrackId ? props.subtitlesOffset : props.selectedExtraSubtitlesTrackId ? props.extraSubtitlesOffset : null}
unit={'%'} unit={'%'}
step={1} step={1}

View file

@ -102,8 +102,8 @@ const usePlayer = (urlParams) => {
args: { args: {
action: 'TimeChanged', action: 'TimeChanged',
args: { args: {
time: Math.round(time), time: Math.max(0, Math.round(time)),
duration, duration: Math.max(0, Math.round(duration)),
device, device,
} }
} }
@ -118,8 +118,8 @@ const usePlayer = (urlParams) => {
args: { args: {
action: 'Seek', action: 'Seek',
args: { args: {
time: Math.round(time), time: Math.max(0, Math.round(time)),
duration, duration: Math.max(0, Math.round(duration)),
device, device,
} }
} }

View file

@ -12,7 +12,7 @@
} }
.search-container { .search-container {
height: 100%; height: calc(100% - var(--safe-area-inset-bottom));
width: 100%; width: 100%;
background-color: transparent; background-color: transparent;

View file

@ -3,6 +3,7 @@ import { ColorInput, MultiselectMenu, Toggle } from 'stremio/components';
import { useServices } from 'stremio/services'; import { useServices } from 'stremio/services';
import { Category, Option, Section } from '../components'; import { Category, Option, Section } from '../components';
import usePlayerOptions from './usePlayerOptions'; import usePlayerOptions from './usePlayerOptions';
import { usePlatform } from 'stremio/common';
type Props = { type Props = {
profile: Profile, profile: Profile,
@ -10,6 +11,7 @@ type Props = {
const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => { const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
const { shell } = useServices(); const { shell } = useServices();
const platform = usePlatform();
const { const {
subtitlesLanguageSelect, subtitlesLanguageSelect,
@ -26,6 +28,7 @@ const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
bingeWatchingToggle, bingeWatchingToggle,
playInBackgroundToggle, playInBackgroundToggle,
hardwareDecodingToggle, hardwareDecodingToggle,
videoModeSelect,
pauseOnMinimizeToggle, pauseOnMinimizeToggle,
} = usePlayerOptions(profile); } = usePlayerOptions(profile);
@ -129,6 +132,15 @@ const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
/> />
</Option> </Option>
} }
{
shell.active && platform.name === 'windows' &&
<Option label={'SETTINGS_VIDEO_MODE'}>
<MultiselectMenu
className={'multiselect'}
{...videoModeSelect}
/>
</Option>
}
{ {
shell.active && shell.active &&
<Option label={'SETTINGS_PAUSE_MINIMIZED'}> <Option label={'SETTINGS_PAUSE_MINIMIZED'}>

View file

@ -287,6 +287,38 @@ const usePlayerOptions = (profile: Profile) => {
} }
}), [profile.settings]); }), [profile.settings]);
const videoModeSelect = useMemo(() => ({
options: [
{
value: null,
label: t('SETTINGS_VIDEO_MODE_DEFAULT'),
},
{
value: 'legacy',
label: t('SETTINGS_VIDEO_MODE_LEGACY'),
}
],
value: profile.settings.videoMode,
title: () => {
return profile.settings.videoMode === 'legacy' ?
t('SETTINGS_VIDEO_MODE_LEGACY')
:
t('SETTINGS_VIDEO_MODE_DEFAULT');
},
onSelect: (value: string | null) => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
...profile.settings,
videoMode: value,
}
}
});
}
}), [profile.settings]);
const pauseOnMinimizeToggle = useMemo(() => ({ const pauseOnMinimizeToggle = useMemo(() => ({
checked: profile.settings.pauseOnMinimize, checked: profile.settings.pauseOnMinimize,
onClick: () => { onClick: () => {
@ -318,6 +350,7 @@ const usePlayerOptions = (profile: Profile) => {
bingeWatchingToggle, bingeWatchingToggle,
playInBackgroundToggle, playInBackgroundToggle,
hardwareDecodingToggle, hardwareDecodingToggle,
videoModeSelect,
pauseOnMinimizeToggle, pauseOnMinimizeToggle,
}; };
}; };

View file

@ -1,27 +1,4 @@
.shortcut-container { .shortcuts-group {
display: flex; width: 100%;
align-items: center; margin-bottom: 3rem;
justify-content: center;
padding: 0;
overflow: visible;
kbd {
flex: 0 1 auto;
height: 2.5rem;
min-width: 2.5rem;
line-height: 2.5rem;
padding: 0 1rem;
font-weight: 500;
color: var(--primary-foreground-color);
border-radius: 0.25em;
box-shadow: 0 4px 0 1px var(--modal-background-color);
background-color: var(--overlay-color);
}
.label {
flex: none;
margin: 0 1rem;
white-space: nowrap;
color: var(--primary-foreground-color);
}
} }

View file

@ -1,97 +1,24 @@
import React, { forwardRef } from 'react'; import React, { forwardRef } from 'react';
import { Section, Option } from '../components'; import { Section } from '../components';
import { ShortcutsGroup } from 'stremio/components';
import { useShortcuts } from 'stremio/common';
import styles from './Shortcuts.less'; import styles from './Shortcuts.less';
import { useTranslation } from 'react-i18next';
const Shortcuts = forwardRef<HTMLDivElement>((_, ref) => { const Shortcuts = forwardRef<HTMLDivElement>((_, ref) => {
const { t } = useTranslation(); const { grouped } = useShortcuts();
return ( return (
<Section ref={ref} label={'SETTINGS_NAV_SHORTCUTS'}> <Section ref={ref} label={'SETTINGS_NAV_SHORTCUTS'}>
<Option label={'SETTINGS_SHORTCUT_PLAY_PAUSE'}> {
<div className={styles['shortcut-container']}> grouped.map(({ name, label, shortcuts }) => (
<kbd>{t('SETTINGS_SHORTCUT_SPACE')}</kbd> <ShortcutsGroup
</div> key={name}
</Option> className={styles['shortcuts-group']}
<Option label={'SETTINGS_SHORTCUT_SEEK_FORWARD'}> label={label}
<div className={styles['shortcut-container']}> shortcuts={shortcuts}
<kbd></kbd> />
<div className={styles['label']}>{t('SETTINGS_SHORTCUT_OR')}</div> ))
<kbd> {t('SETTINGS_SHORTCUT_SHIFT')}</kbd> }
<div className={styles['label']}>+</div>
<kbd></kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_SEEK_BACKWARD'}>
<div className={styles['shortcut-container']}>
<kbd></kbd>
<div className={styles['label']}>{t('SETTINGS_SHORTCUT_OR')}</div>
<kbd> {t('SETTINGS_SHORTCUT_SHIFT')}</kbd>
<div className={styles['label']}>+</div>
<kbd></kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_VOLUME_UP'}>
<div className={styles['shortcut-container']}>
<kbd></kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_VOLUME_DOWN'}>
<div className={styles['shortcut-container']}>
<kbd></kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_MENU_SUBTITLES'}>
<div className={styles['shortcut-container']}>
<kbd>S</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_MENU_AUDIO'}>
<div className={styles['shortcut-container']}>
<kbd>A</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_MENU_INFO'}>
<div className={styles['shortcut-container']}>
<kbd>I</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_FULLSCREEN'}>
<div className={styles['shortcut-container']}>
<kbd>F</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_SUBTITLES_SIZE'}>
<div className={styles['shortcut-container']}>
<kbd>-</kbd>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_AND') }</div>
<kbd>=</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_SUBTITLES_DELAY'}>
<div className={styles['shortcut-container']}>
<kbd>G</kbd>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_AND') }</div>
<kbd>H</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_NAVIGATE_MENUS'}>
<div className={styles['shortcut-container']}>
<kbd>1</kbd>
<div className={styles['label']}>{t('SETTINGS_SHORTCUT_TO')}</div>
<kbd>6</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_GO_TO_SEARCH'}>
<div className={styles['shortcut-container']}>
<kbd>0</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_EXIT_BACK'}>
<div className={styles['shortcut-container']}>
<kbd>{t('SETTINGS_SHORTCUT_ESC')}</kbd>
</div>
</Option>
</Section> </Section>
); );
}); });

View file

@ -17,7 +17,7 @@ const AddItem = ({ onCancel, handleAddUrl }: Props) => {
setInputValue(target.value); setInputValue(target.value);
}, []); }, []);
const onSumbit = useCallback(() => { const onSubmit = useCallback(() => {
handleAddUrl(inputValue); handleAddUrl(inputValue);
}, [inputValue]); }, [inputValue]);
@ -27,11 +27,11 @@ const AddItem = ({ onCancel, handleAddUrl }: Props) => {
className={styles['input']} className={styles['input']}
value={inputValue} value={inputValue}
onChange={handleValueChange} onChange={handleValueChange}
onSubmit={onSumbit} onSubmit={onSubmit}
placeholder={'Enter URL'} placeholder={'Enter URL'}
/> />
<div className={styles['actions']}> <div className={styles['actions']}>
<Button className={styles['add']} onClick={onSumbit}> <Button className={styles['add']} onClick={onSubmit}>
<Icon name={'checkmark'} className={styles['icon']} /> <Icon name={'checkmark'} className={styles['icon']} />
</Button> </Button>
<Button className={styles['cancel']} onClick={onCancel}> <Button className={styles['cancel']} onClick={onCancel}>

View file

@ -21,7 +21,7 @@ const initialize = () => {
if (castAPIAvailable) { if (castAPIAvailable) {
resolve(); resolve();
} else { } else {
reject(new Error('window.cast api not available')); reject(new Error('window.cast api not available', { cause: 'castAPIAvailable is null.' }));
} }
} }
if (castAPIAvailable !== null) { if (castAPIAvailable !== null) {
@ -167,7 +167,7 @@ function ChromecastTransport() {
}); });
})); }));
} else { } else {
return Promise.reject(new Error('Session not started')); return Promise.reject(new Error('Session not started', { cause: 'castSession is null.' }));
} }
}; };
} }

View file

@ -63,7 +63,7 @@ function Shell() {
} catch (e) { } catch (e) {
console.error(e); console.error(e);
active = false; active = false;
error = new Error(e); error = new Error('Failed to initialize shell transport', { cause: e });
starting = false; starting = false;
onStateChanged(); onStateChanged();
transport = null; transport = null;

View file

@ -1,5 +1,3 @@
/* eslint-disable no-var */
type QtTransportMessage = { type QtTransportMessage = {
data: string; data: string;
}; };
@ -28,4 +26,4 @@ declare global {
var chrome: Chrome | undefined; var chrome: Chrome | undefined;
} }
export {}; export { };

View file

@ -19,6 +19,7 @@ type Settings = {
autoFrameRateMatching: boolean, autoFrameRateMatching: boolean,
bingeWatching: boolean, bingeWatching: boolean,
hardwareDecoding: boolean, hardwareDecoding: boolean,
videoMode: string | null,
escExitFullscreen: boolean, escExitFullscreen: boolean,
interfaceLanguage: string, interfaceLanguage: string,
quitOnClose: boolean, quitOnClose: boolean,

View file

@ -3,7 +3,7 @@ type LibraryItemPlayer = Pick<LibraryItem, '_id'> & {
}; };
type VideoPlayer = Video & { type VideoPlayer = Video & {
upcomming: boolean, upcoming: boolean,
watched: boolean, watched: boolean,
progress: boolean | null, progress: boolean | null,
scheduled: boolean, scheduled: boolean,

View file

@ -1,6 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"lib": ["DOM", "DOM.Iterable"], "lib": ["ESNext", "DOM", "DOM.Iterable"],
"jsx": "react", "jsx": "react",
"baseUrl": "./src", "baseUrl": "./src",
"outDir": "./dist", "outDir": "./dist",

View file

@ -12,7 +12,7 @@ const WorkboxPlugin = require('workbox-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin'); const CopyWebpackPlugin = require('copy-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin'); const TerserPlugin = require('terser-webpack-plugin');
const WebpackPwaManifest = require('webpack-pwa-manifest'); const WebpackPwaManifest = require('webpack-pwa-manifest');
const pachageJson = require('./package.json'); const packageJson = require('./package.json');
const COMMIT_HASH = execSync('git rev-parse HEAD').toString().trim(); const COMMIT_HASH = execSync('git rev-parse HEAD').toString().trim();
@ -213,8 +213,9 @@ module.exports = (env, argv) => ({
new webpack.EnvironmentPlugin({ new webpack.EnvironmentPlugin({
SENTRY_DSN: null, SENTRY_DSN: null,
...env, ...env,
SERVICE_WORKER_DISABLED: false,
DEBUG: argv.mode !== 'production', DEBUG: argv.mode !== 'production',
VERSION: pachageJson.version, VERSION: packageJson.version,
COMMIT_HASH COMMIT_HASH
}), }),
new webpack.ProvidePlugin({ new webpack.ProvidePlugin({