Merge branch 'development' into feat/captions-shortkey

This commit is contained in:
Neeraj TK 2025-11-14 17:32:10 +05:30 committed by GitHub
commit e1e6fe075b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
277 changed files with 17699 additions and 18296 deletions

66
.github/workflows/auto_assign.yml vendored Normal file
View file

@ -0,0 +1,66 @@
name: PR and Issue Workflow
on:
pull_request:
types: [opened, reopened]
issues:
types: [opened]
jobs:
auto-assign-and-label:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
# Auto assign PR to author
- name: Auto Assign PR to Author
if: github.event.pull_request.head.repo.fork == false && github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const pr = context.payload.pull_request;
if (pr) {
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
assignees: [pr.user.login]
});
console.log(`Assigned PR #${pr.number} to author @${pr.user.login}`);
}
# Dynamic labeling based on PR/Issue title
- name: Label PRs and Issues
if: github.event.pull_request.head.repo.fork == false && github.actor != 'dependabot[bot]'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: actions/github-script@v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const prTitle = context.payload.pull_request ? context.payload.pull_request.title : context.payload.issue.title;
const issueNumber = context.payload.pull_request ? context.payload.pull_request.number : context.payload.issue.number;
const isIssue = context.payload.issue !== undefined;
const labelMappings = [
{ pattern: /^feat(ure)?/i, label: 'feature' },
{ pattern: /^fix/i, label: 'bug' },
{ pattern: /^refactor/i, label: 'refactor' },
{ pattern: /^chore/i, label: 'chore' },
{ pattern: /^docs?/i, label: 'documentation' },
{ pattern: /^perf(ormance)?/i, label: 'performance' },
{ pattern: /^test/i, label: 'testing' }
];
let labelsToAdd = [];
for (const mapping of labelMappings) {
if (mapping.pattern.test(prTitle)) {
labelsToAdd.push(mapping.label);
}
}
if (labelsToAdd.length > 0) {
github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: labelsToAdd
});
}

View file

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

53
.github/workflows/pages_cleanup.yml vendored Normal file
View file

@ -0,0 +1,53 @@
name: GitHub Pages Cleanup
on:
schedule:
- cron: '0 0 * * 0'
workflow_dispatch:
permissions:
contents: write
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
with:
ref: gh-pages
fetch-depth: 0
- name: Delete directories that don't have existing branch
run: |
branches=( $(git branch -r | grep origin | grep -v HEAD | sed 's|origin/||') )
declare -p branches
find . -mindepth 1 -maxdepth 2 -type d -not -path '*/\.*' | while read -r dir; do
path="${dir#./}"
if [[ " ${branches[*]} " =~ " $path " ]]; then
continue
fi
keep_parent=false
for branch in "${branches[@]}"; do
if [[ "$branch" == "$path/"* ]]; then
keep_parent=true
break
fi
done
if ! $keep_parent; then
echo "Deleting $dir"
rm -rf "$dir"
fi
done
- name: Commit and push
run: |
git config --global user.name 'GitHub Pages Cleanup'
git config --global user.email 'actions@stremio.com'
git add -A
git diff --cached --quiet || git commit -m "cleanup"
git push origin gh-pages

View file

@ -9,26 +9,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install NPM dependencies
run: npm install
run: pnpm install
- name: Build
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
run: npm run build
run: pnpm build
- name: Zip build artifact
run: zip -r stremio-web.zip ./build
- name: Upload build artifact to GitHub release assets
uses: svenstaro/upload-release-action@2.9.0
uses: svenstaro/upload-release-action@2.11.2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: stremio-web.zip
asset_name: stremio-web.zip
tag: ${{ github.ref }}
overwrite: true
- name: Upload build artifact to Netlify
run: |
curl -H "Content-Type: application/zip" \
-H "Authorization: Bearer ${{ secrets.netlify_access_token }}" \
--data-binary "@stremio-web.zip" \
https://api.netlify.com/api/v1/sites/stremio-development.netlify.com/deploys

View file

@ -0,0 +1,26 @@
{
"applinks": {
"apps": [],
"details": [
{
"appIDs": [
"9EWRZ4QP3J.com.stremio.one"
],
"appID": "9EWRZ4QP3J.com.stremio.one",
"paths": [
"*"
]
}
]
},
"activitycontinuation": {
"apps": [
"9EWRZ4QP3J.com.stremio.one"
]
},
"webcredentials": {
"apps": [
"9EWRZ4QP3J.com.stremio.one"
]
}
}

View file

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

View file

@ -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"]

View file

@ -1,6 +1,6 @@
# 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)
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
* Node.js 12 or higher
* npm 6 or higher
* [pnpm](https://pnpm.io/installation) 10 or higher
### Install dependencies
```bash
npm install
pnpm install
```
### Start development server
```bash
npm start
pnpm start
```
### Production build
```bash
npm run build
pnpm run build
```
### Run with Docker
```bash
docker build -t stremio-web .
docker run -p 8080:8080 stremio-web
```
## Screenshots

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

15495
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{
"name": "stremio",
"displayName": "Stremio",
"version": "5.0.0-beta.16",
"version": "5.0.0-beta.27",
"author": "Smart Code OOD",
"private": true,
"license": "gpl-2.0",
@ -10,15 +10,16 @@
"start-prod": "webpack serve --mode production",
"build": "webpack --mode production",
"test": "jest",
"lint": "eslint src"
"lint": "eslint src",
"scan-translations": "pnpx jest ./tests/i18nScan.test.js"
},
"dependencies": {
"@babel/runtime": "7.26.0",
"@sentry/browser": "8.42.0",
"@stremio/stremio-colors": "5.2.0",
"@stremio/stremio-core-web": "0.48.4",
"@stremio/stremio-icons": "5.4.1",
"@stremio/stremio-video": "0.0.48",
"@stremio/stremio-core-web": "0.50.0",
"@stremio/stremio-icons": "5.7.1",
"@stremio/stremio-video": "0.0.64",
"a-color-picker": "1.2.1",
"bowser": "2.11.0",
"buffer": "6.0.3",
@ -40,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#a0f50634202f748a57907b645d2cd92fbaa479dd",
"stremio-translations": "github:Stremio/stremio-translations#01aaa201e419782b26b9f2cbe4430795021426e5",
"url": "0.11.4",
"use-long-press": "^3.2.0"
},
@ -49,9 +50,11 @@
"@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.26.3",
"@eslint/js": "^9.16.0",
"@stylistic/eslint-plugin": "^2.11.0",
"@stylistic/eslint-plugin-jsx": "^2.11.0",
"@stylistic/eslint-plugin": "^5.4.0",
"@stylistic/eslint-plugin-jsx": "^4.4.1",
"@types/hat": "^0.0.4",
"@types/lodash.isequal": "^4.5.8",
"@types/lodash.throttle": "^4.1.9",
"@types/react": "^18.3.13",
"@types/react-dom": "^18.3.1",
"babel-loader": "9.2.1",
@ -70,6 +73,7 @@
"mini-css-extract-plugin": "2.9.2",
"postcss-loader": "8.1.1",
"readdirp": "4.0.2",
"recast": "0.23.11",
"terser-webpack-plugin": "5.3.10",
"thread-loader": "^4.0.4",
"ts-loader": "^9.5.1",

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,10 +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 { PlatformProvider, ToastProvider, TooltipProvider, CONSTANTS, withCoreSuspender } = 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');
@ -19,6 +21,7 @@ const RouterWithProtectedRoutes = withCoreSuspender(withProtectedRoutes(Router))
const App = () => {
const { i18n } = useTranslation();
const shell = useShell();
const onPathNotMatch = React.useCallback(() => {
return NotFound;
}, []);
@ -36,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 = () => {
@ -96,6 +107,32 @@ const App = () => {
services.chromecast.off('stateChanged', onChromecastStateChange);
};
}, []);
// Handle shell events
React.useEffect(() => {
const onOpenMedia = (data) => {
try {
const { protocol, hostname, pathname, searchParams } = new URL(data);
if (protocol === CONSTANTS.PROTOCOL) {
if (hostname.length) {
const transportUrl = `https://${hostname}${pathname}`;
window.location.href = `#/addons?addon=${encodeURIComponent(transportUrl)}`;
} else {
window.location.href = `#${pathname}?${searchParams.toString()}`;
}
}
} catch (e) {
console.error('Failed to open media:', e);
}
};
shell.on('open-media', onOpenMedia);
return () => {
shell.off('open-media', onOpenMedia);
};
}, []);
React.useEffect(() => {
const onCoreEvent = ({ event, args }) => {
switch (event) {
@ -103,6 +140,11 @@ const App = () => {
if (args && args.settings && typeof args.settings.interfaceLanguage === 'string') {
i18n.changeLanguage(args.settings.interfaceLanguage);
}
if (args?.settings?.quitOnClose && shell.windowClosed) {
shell.send('quit');
}
break;
}
}
@ -111,6 +153,10 @@ const App = () => {
if (state && state.profile && state.profile.settings && typeof state.profile.settings.interfaceLanguage === 'string') {
i18n.changeLanguage(state.profile.settings.interfaceLanguage);
}
if (state?.profile?.settings?.quitOnClose && shell.windowClosed) {
shell.send('quit');
}
};
const onWindowFocus = () => {
services.core.transport.dispatch({
@ -122,7 +168,8 @@ const App = () => {
services.core.transport.dispatch({
action: 'Ctx',
args: {
action: 'PullUserFromAPI'
action: 'PullUserFromAPI',
args: {}
}
});
services.core.transport.dispatch({
@ -145,7 +192,7 @@ const App = () => {
services.core.transport
.getState('ctx')
.then(onCtxState)
.catch((e) => console.error(e));
.catch(console.error);
}
return () => {
if (services.core.active) {
@ -153,7 +200,7 @@ const App = () => {
services.core.transport.off('CoreEvent', onCoreEvent);
}
};
}, [initialized]);
}, [initialized, shell.windowClosed]);
return (
<React.StrictMode>
<ServicesProvider services={services}>
@ -165,14 +212,22 @@ const App = () => {
<PlatformProvider>
<ToastProvider className={styles['toasts-container']}>
<TooltipProvider className={styles['tooltip-container']}>
<ServicesToaster />
<DeepLinkHandler />
<SearchParamsHandler />
<RouterWithProtectedRoutes
className={styles['router']}
viewsConfig={routerViewsConfig}
onPathNotMatch={onPathNotMatch}
/>
<FileDropProvider className={styles['file-drop-container']}>
<ShortcutsProvider onShortcut={onShortcut}>
{
shortcutModalOpen && <ShortcutsModal onClose={closeShortcutsModal}/>
}
<ServicesToaster />
<DeepLinkHandler />
<SearchParamsHandler />
<UpdaterBanner className={styles['updater-banner-container']} />
<RouterWithProtectedRoutes
className={styles['router']}
viewsConfig={routerViewsConfig}
onPathNotMatch={onPathNotMatch}
/>
</ShortcutsProvider>
</FileDropProvider>
</TooltipProvider>
</ToastProvider>
</PlatformProvider>

View file

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

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

@ -0,0 +1,46 @@
.updater-banner {
height: 4rem;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 0 1rem;
font-size: 1rem;
font-weight: bold;
color: var(--primary-foreground-color);
background-color: var(--primary-accent-color);
.button {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
height: 2.5rem;
padding: 0 1rem;
border-radius: var(--border-radius);
color: var(--primary-background-color);
background-color: var(--primary-foreground-color);
transition: all 0.1s ease-out;
&:hover {
color: var(--primary-foreground-color);
background-color: transparent;
box-shadow: inset 0 0 0 0.15rem var(--primary-foreground-color);
}
}
.close {
position: absolute;
right: 0;
height: 4rem;
width: 4rem;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
.icon {
height: 2rem;
}
}
}

View file

@ -0,0 +1,50 @@
import React, { useEffect } from 'react';
import Icon from '@stremio/stremio-icons/react';
import { useTranslation } from 'react-i18next';
import { useServices } from 'stremio/services';
import { useBinaryState, useShell } from 'stremio/common';
import { Button, Transition } from 'stremio/components';
import styles from './UpdaterBanner.less';
type Props = {
className: string,
};
const UpdaterBanner = ({ className }: Props) => {
const { t } = useTranslation();
const { shell } = useServices();
const shellTransport = useShell();
const [visible, show, hide] = useBinaryState(false);
const onInstallClick = () => {
shellTransport.send('autoupdater-notif-clicked');
};
useEffect(() => {
shell.transport && shell.transport.on('autoupdater-show-notif', show);
return () => {
shell.transport && shell.transport.off('autoupdater-show-notif', show);
};
}, []);
return (
<div className={className}>
<Transition when={visible} name={'slide-up'}>
<div className={styles['updater-banner']}>
<div className={styles['label']}>
{ t('UPDATER_TITLE') }
</div>
<Button className={styles['button']} onClick={onInstallClick}>
{ t('UPDATER_INSTALL_BUTTON') }
</Button>
<Button className={styles['close']} onClick={hide}>
<Icon className={styles['icon']} name={'close'} />
</Button>
</div>
</Transition>
</div>
);
};
export default UpdaterBanner;

View file

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

View file

@ -14,9 +14,19 @@
}
// iOS pads the bottom inset more than needed, so we deduce the actual inset size when using the webapp
@calculated-bottom-safe-inset: ~"min(env(safe-area-inset-bottom, 0rem), max(1rem, calc(100lvh - 100svh - env(safe-area-inset-top, 0rem))))";
@html-width: ~"calc(max(100svw, 100dvw))";
@html-height: ~"calc(max(100svh, 100dvh))";
@calculated-bottom-safe-inset: ~"min(env(safe-area-inset-bottom, 0rem), max(1rem, calc(var(--viewport-height-diff) - env(safe-area-inset-top, 0rem))))";
// Viewport sizes
@viewport-width: ~"100vw";
@viewport-height: ~"100vh";
// HTML sizes
@html-width: ~"calc(max(var(--small-viewport-width), var(--dynamic-viewport-width)))";
@html-height: ~"calc(max(var(--small-viewport-height), var(--dynamic-viewport-height)))";
@html-standalone-width: ~"calc(max(100%, var(--small-viewport-width)))";
@html-standalone-height: ~"calc(max(100%, var(--small-viewport-height)))";
// Safe area insets
@safe-area-inset-top: env(safe-area-inset-top, 0rem);
@safe-area-inset-right: env(safe-area-inset-right, 0rem);
@safe-area-inset-bottom: env(safe-area-inset-bottom, 0rem);
@ -25,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;
@ -53,16 +63,47 @@
--overlay-color: rgba(255, 255, 255, 0.05);
--modal-background-color: rgba(15, 13, 32, 1);
--outer-glow: 0px 0px 15px rgba(123, 91, 245, 0.37);
--warning-accent-color: rgba(255, 165, 0, 1);
--danger-accent-color: rgba(220, 38, 38, 1);
--border-radius: 0.75rem;
--calculated-bottom-safe-inset: @calculated-bottom-safe-inset;
--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;
--safe-area-inset-left: @safe-area-inset-left;
--dynamic-viewport-width: @viewport-width;
--dynamic-viewport-height: @viewport-height;
--large-viewport-width: @viewport-width;
--large-viewport-height: @viewport-height;
--small-viewport-width: @viewport-width;
--small-viewport-height: @viewport-height;
--viewport-height-diff: calc(100vh - 100vh);
@supports (height: 100dvh) {
--dynamic-viewport-width: 100dvw;
--dynamic-viewport-height: 100dvh;
}
@supports (height: 100lvh) {
--large-viewport-width: 100lvw;
--large-viewport-height: 100lvh;
}
@supports (height: 100svh) {
--small-viewport-width: 100svw;
--small-viewport-height: 100svh;
}
@supports (height: 100lvh) and (height: 100svh) {
--viewport-height-diff: calc(100lvh - 100svh);
}
@media (display-mode: standalone) {
--safe-area-inset-bottom: @calculated-bottom-safe-inset;
}
}
* {
@ -110,19 +151,23 @@ svg {
html {
width: @html-width;
height: @html-height;
min-width: 640px;
min-height: 480px;
font-family: 'PlusJakartaSans', 'sans-serif';
font-family: 'PlusJakartaSans', 'Arial', 'Helvetica', 'sans-serif';
overflow: auto;
overscroll-behavior: none;
user-select: none;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
@media (display-mode: standalone) {
width: @html-standalone-width;
height: @html-standalone-height;
}
body {
width: 100%;
height: 100%;
background: linear-gradient(41deg, var(--primary-background-color) 0%, var(--secondary-background-color) 100%);
-webkit-font-smoothing: antialiased;
:global(#app) {
position: relative;
@ -134,7 +179,7 @@ html {
position: absolute;
top: calc(1.2 * var(--horizontal-nav-bar-size) + var(--safe-area-inset-top));
right: var(--safe-area-inset-right);
bottom: calc(1.2 * var(--horizontal-nav-bar-size) + var(--calculated-bottom-safe-inset, 0rem));
bottom: calc(1.2 * var(--horizontal-nav-bar-size) + var(--safe-area-inset-bottom, 0rem));
left: auto;
z-index: 1;
padding: 0 calc(0.5 * var(--horizontal-nav-bar-size));
@ -159,12 +204,32 @@ html {
background-color: var(--modal-background-color);
box-shadow: var(--outer-glow);
transition: opacity 0.1s ease-out;
}
.file-drop-container {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
border-radius: 1rem;
border: 0.5rem dashed transparent;
pointer-events: none;
transition: border-color 0.25s ease-out;
&:global(.active) {
transition-delay: 0.25s;
border-color: var(--primary-accent-color);
}
}
.updater-banner-container {
z-index: 1;
position: absolute;
left: 0;
right: 0;
bottom: 0;
}
.router {
width: 100%;
height: 100%;
@ -204,9 +269,6 @@ html {
@media only screen and (max-width: @xsmall) {
html {
min-width: inherit;
min-height: inherit;
body {
:global(#app) {
.toasts-container {

View file

@ -41,6 +41,16 @@ const ICON_FOR_TYPE = new Map([
['other', 'movies'],
]);
const MIME_SIGNATURES = {
'application/x-subrip': ['310D0A', '310A'],
'text/vtt': ['574542565454'],
};
const SUPPORTED_LOCAL_SUBTITLES = [
'application/x-subrip',
'text/vtt',
];
const EXTERNAL_PLAYERS = [
{
label: 'EXTERNAL_PLAYER_DISABLED',
@ -96,6 +106,8 @@ const EXTERNAL_PLAYERS = [
const WHITELISTED_HOSTS = ['stremio.com', 'strem.io', 'stremio.zendesk.com', 'google.com', 'youtube.com', 'twitch.tv', 'twitter.com', 'x.com', 'netflix.com', 'adex.network', 'amazon.com', 'forms.gle'];
const PROTOCOL = 'stremio:';
module.exports = {
CHROMECAST_RECEIVER_APP_ID,
DEFAULT_STREAMING_SERVER_URL,
@ -113,6 +125,9 @@ module.exports = {
WRITERS_LINK_CATEGORY,
TYPE_PRIORITIES,
ICON_FOR_TYPE,
MIME_SIGNATURES,
SUPPORTED_LOCAL_SUBTITLES,
EXTERNAL_PLAYERS,
WHITELISTED_HOSTS,
PROTOCOL,
};

View file

@ -0,0 +1,91 @@
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import classNames from 'classnames';
import { isFileType } from './utils';
export type FileType = string;
export type FileDropListener = (filename: string, buffer: ArrayBuffer) => void;
type FileDropContext = {
on: (type: FileType, listener: FileDropListener) => void,
off: (type: FileType, listener: FileDropListener) => void,
};
const FileDropContext = createContext({} as FileDropContext);
type Props = {
className: string,
children: JSX.Element,
};
const FileDropProvider = ({ className, children }: Props) => {
const [listeners, setListeners] = useState<[FileType, FileDropListener][]>([]);
const [active, setActive] = useState(false);
const onDragOver = (event: DragEvent) => {
event.preventDefault();
setActive(true);
};
const onDragLeave = () => {
setActive(false);
};
const onDrop = useCallback((event: DragEvent) => {
event.preventDefault();
const { dataTransfer } = event;
if (dataTransfer && dataTransfer?.files.length > 0) {
const file = dataTransfer.files[0];
file
.arrayBuffer()
.then((buffer) => {
listeners
.filter(([type]) => file.type ? type === file.type : isFileType(buffer, type))
.forEach(([, listener]) => listener(file.name, buffer));
});
}
setActive(false);
}, [listeners]);
const on = (type: FileType, listener: FileDropListener) => {
setListeners((listeners) => {
return [...listeners, [type, listener]];
});
};
const off = (type: FileType, listener: FileDropListener) => {
setListeners((listeners) => {
return listeners.filter(([key, value]) => key !== type && value !== listener);
});
};
useEffect(() => {
window.addEventListener('dragover', onDragOver);
window.addEventListener('dragleave', onDragLeave);
window.addEventListener('drop', onDrop);
return () => {
window.removeEventListener('dragover', onDragOver);
window.removeEventListener('dragleave', onDragLeave);
window.removeEventListener('drop', onDrop);
};
}, [onDrop]);
return (
<FileDropContext.Provider value={{ on, off }}>
{ children }
<div className={classNames(className, { 'active': active })} />
</FileDropContext.Provider>
);
};
const useFileDrop = () => {
return useContext(FileDropContext);
};
export {
FileDropProvider,
useFileDrop,
};

View file

@ -0,0 +1,8 @@
import { FileDropProvider, useFileDrop } from './FileDrop';
import onFileDrop from './onFileDrop';
export {
FileDropProvider,
useFileDrop,
onFileDrop,
};

View file

@ -0,0 +1,14 @@
import { useEffect } from 'react';
import { type FileType, type FileDropListener, useFileDrop } from './FileDrop';
const onFileDrop = (types: FileType[], listener: FileDropListener) => {
const { on, off } = useFileDrop();
useEffect(() => {
types.forEach((type) => on(type, listener));
return () => types.forEach((type) => off(type, listener));
}, []);
};
export default onFileDrop;

View file

@ -0,0 +1,19 @@
import { MIME_SIGNATURES } from 'stremio/common/CONSTANTS';
const SIGNATURES = MIME_SIGNATURES as Record<string, string[]>;
const isFileType = (buffer: ArrayBuffer, type: string) => {
const signatures = SIGNATURES[type];
return signatures.some((signature) => {
const array = new Uint8Array(buffer);
const signatureBuffer = Buffer.from(signature, 'hex');
const bufferToCompare = array.subarray(0, signatureBuffer.length);
return Buffer.compare(signatureBuffer, bufferToCompare) === 0;
});
};
export {
isFileType,
};

View file

@ -1,6 +1,5 @@
import React, { createContext, useContext } from 'react';
import { WHITELISTED_HOSTS } from 'stremio/common/CONSTANTS';
import useShell from './useShell';
import { name, isMobile } from './device';
interface PlatformContext {
@ -16,19 +15,13 @@ type Props = {
};
const PlatformProvider = ({ children }: Props) => {
const shell = useShell();
const openExternal = (url: string) => {
try {
const { hostname } = new URL(url);
const isWhitelisted = WHITELISTED_HOSTS.some((host: string) => hostname.endsWith(host));
const finalUrl = !isWhitelisted ? `https://www.stremio.com/warning#${encodeURIComponent(url)}` : url;
if (shell.active) {
shell.send('open-external', finalUrl);
} else {
window.open(finalUrl, '_blank');
}
window.open(finalUrl, '_blank');
} catch (e) {
console.error('Failed to parse external url:', e);
}

View file

@ -1,22 +0,0 @@
const createId = () => Math.floor(Math.random() * 9999) + 1;
const useShell = () => {
const transport = globalThis?.qt?.webChannelTransport;
const send = (method: string, ...args: (string | number)[]) => {
transport?.send(JSON.stringify({
id: createId(),
type: 6,
object: 'transport',
method: 'onEvent',
args: [method, ...args],
}));
};
return {
active: !!transport,
send,
};
};
export default useShell;

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

@ -1,6 +1,7 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const { useTranslation } = require('react-i18next');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { default: Icon } = require('@stremio/stremio-icons/react');
@ -8,6 +9,7 @@ const { Button } = require('stremio/components');
const styles = require('./styles');
const ToastItem = ({ title, message, dataset, onSelect, onClose, ...props }) => {
const { t } = useTranslation();
const type = React.useMemo(() => {
return ['success', 'alert', 'info', 'error'].includes(props.type) ?
props.type
@ -74,7 +76,7 @@ const ToastItem = ({ title, message, dataset, onSelect, onClose, ...props }) =>
null
}
</div>
<Button className={styles['close-button-container']} title={'Close'} tabIndex={-1} onClick={closeButtonOnClick}>
<Button className={styles['close-button-container']} title={t('BUTTON_CLOSE')} tabIndex={-1} onClick={closeButtonOnClick}>
<Icon className={styles['icon']} name={'close'} />
</Button>
</Button>

11
src/common/Toast/useToast.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
type ToastOptions = {
type: string,
title: string,
timeout: number,
};
declare const useToast: () => {
show: (options: ToastOptions) => void,
};
export = useToast;

View file

@ -6,13 +6,29 @@
}
animation-timing-function: ease-in-out;
animation-duration: 100ms;
animation-duration: 350ms;
@media (prefers-reduced-motion) {
:local {
animation-name: fade-in-no-motion;
}
}
}
@keyframes fade-in {
0% {
opacity: 0;
transform: translateY(4px);
}
40% {
opacity: 0;
transform: translateY(4px);
}
70% {
opacity: 0.6;
transform: translateY(0.2vh);
transform: translateY(2px);
}
100% {
@ -51,4 +67,48 @@
.slide-left-exit {
transform: translateX(100%);
}
.slide-up-enter {
transform: translateY(100%);
}
.slide-up-active {
transform: translateY(0%);
transition: transform 0.3s cubic-bezier(0.32, 0, 0.67, 0);
}
.slide-up-exit {
transform: translateY(100%);
}
.fade-enter {
opacity: 0;
}
.fade-active {
opacity: 1;
transition: opacity 0.3s cubic-bezier(0.32, 0, 0.67, 0);
}
.fade-exit {
opacity: 0;
}
@keyframes fade-in-no-motion {
0% {
opacity: 0;
}
40% {
opacity: 0;
}
70% {
opacity: 0.6;
}
100% {
opacity: 1;
}
}

View file

@ -1,8 +1,10 @@
// Copyright (C) 2017-2023 Smart code 203358507
const { FileDropProvider, onFileDrop } = require('./FileDrop');
const { PlatformProvider, usePlatform } = require('./Platform');
const { ToastProvider, useToast } = require('./Toast');
const { TooltipProvider, Tooltip } = require('./Tooltips');
const { ShortcutsProvider, useShortcuts } = require('./Shortcuts');
const comparatorWithPriorities = require('./comparatorWithPriorities');
const CONSTANTS = require('./CONSTANTS');
const { withCoreSuspender, useCoreSuspender } = require('./CoreSuspender');
@ -13,19 +15,29 @@ const languages = require('./languages');
const routesRegexp = require('./routesRegexp');
const useAnimationFrame = require('./useAnimationFrame');
const useBinaryState = require('./useBinaryState');
const useFullscreen = require('./useFullscreen');
const { default: useFullscreen } = require('./useFullscreen');
const { default: useInterval } = require('./useInterval');
const useLiveRef = require('./useLiveRef');
const useModelState = require('./useModelState');
const useNotifications = require('./useNotifications');
const useOnScrollToBottom = require('./useOnScrollToBottom');
const useProfile = require('./useProfile');
const { default: useSettings } = require('./useSettings');
const { default: useShell } = require('./useShell');
const useStreamingServer = require('./useStreamingServer');
const { default: useTimeout } = require('./useTimeout');
const useTorrent = require('./useTorrent');
const useTranslate = require('./useTranslate');
const { default: useOrientation } = require('./useOrientation');
const { default: useLanguageSorting } = require('./useLanguageSorting');
module.exports = {
FileDropProvider,
onFileDrop,
PlatformProvider,
usePlatform,
ShortcutsProvider,
useShortcuts,
ToastProvider,
useToast,
TooltipProvider,
@ -42,12 +54,18 @@ module.exports = {
useAnimationFrame,
useBinaryState,
useFullscreen,
useInterval,
useLiveRef,
useModelState,
useNotifications,
useOnScrollToBottom,
useProfile,
useSettings,
useShell,
useStreamingServer,
useTimeout,
useTorrent,
useTranslate,
useOrientation,
useLanguageSorting,
};

View file

@ -51,6 +51,10 @@
"name": "فارسی",
"codes": ["fa-IR", "fas"]
},
{
"name": "Suomi",
"codes": ["fi-FI", "fin"]
},
{
"name": "Français",
"codes": ["fr-FR", "fre"]
@ -119,13 +123,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,6 +143,10 @@
"name": "српски језик",
"codes": ["sr-RS", "srp"]
},
{
"name": "Svenska",
"codes": ["sv-SE", "swe"]
},
{
"name": "తెలుగు",
"codes": ["te-IN", "tel"]

View file

@ -6,7 +6,7 @@ const routesRegexp = {
urlParamsNames: []
},
board: {
regexp: /^\/?$/,
regexp: /^\/?(?:board)?$/,
urlParamsNames: []
},
discover: {

View file

@ -9,4 +9,17 @@
@large: 2200px;
@xlarge: 2500px;
@xxlarge: 2800px;
@xxxlarge: 3800px;
@xxxlarge: 3800px;
@small-phone-landscape-size: 400px;
@small-phone-portrait-size: 700px;
@phone-landscape-size: 500px;
@phone-portrait-size: 1000px;
@small-phone-landscape: ~"screen and (max-width: @{small-phone-portrait-size}) and (max-height: @{small-phone-landscape-size}) and (orientation: landscape)";
@small-phone-portrait: ~"screen and (max-width: @{small-phone-landscape-size}) and (max-height: @{small-phone-portrait-size}) and (orientation: portrait)";
@phone-landscape: ~"screen and (max-width: @{phone-portrait-size}) and (max-height: @{phone-landscape-size}) and (orientation: landscape)";
@phone-portrait: ~"screen and (max-width: @{phone-landscape-size}) and (max-height: @{phone-portrait-size}) and (orientation: portrait)";

View file

@ -1,32 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const useFullscreen = () => {
const [fullscreen, setFullscreen] = React.useState(document.fullscreenElement === document.documentElement);
const requestFullscreen = React.useCallback(() => {
document.documentElement.requestFullscreen();
}, []);
const exitFullscreen = React.useCallback(() => {
document.exitFullscreen();
}, []);
const toggleFullscreen = React.useCallback(() => {
if (fullscreen) {
exitFullscreen();
} else {
requestFullscreen();
}
}, [fullscreen]);
React.useEffect(() => {
const onFullscreenChange = () => {
setFullscreen(document.fullscreenElement === document.documentElement);
};
document.addEventListener('fullscreenchange', onFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', onFullscreenChange);
};
}, []);
return [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen];
};
module.exports = useFullscreen;

View file

@ -0,0 +1,86 @@
// Copyright (C) 2017-2023 Smart code 203358507
import { useCallback, useEffect, useState } from 'react';
import useShell, { type WindowVisibility } from './useShell';
import useSettings from './useSettings';
const useFullscreen = () => {
const shell = useShell();
const [settings] = useSettings();
const [fullscreen, setFullscreen] = useState(false);
const requestFullscreen = useCallback(async () => {
if (shell.active) {
shell.send('win-set-visibility', { fullscreen: true });
} else {
try {
await document.documentElement.requestFullscreen();
} catch (err) {
console.error('Error enabling fullscreen', err);
}
}
}, []);
const exitFullscreen = useCallback(() => {
if (shell.active) {
shell.send('win-set-visibility', { fullscreen: false });
} else {
if (document.fullscreenElement === document.documentElement) {
document.exitFullscreen();
}
}
}, []);
const toggleFullscreen = useCallback(() => {
fullscreen ? exitFullscreen() : requestFullscreen();
}, [fullscreen]);
useEffect(() => {
const onWindowVisibilityChanged = (state: WindowVisibility) => {
setFullscreen(state.isFullscreen === true);
};
const onFullscreenChange = () => {
setFullscreen(document.fullscreenElement === document.documentElement);
};
const onKeyDown = (event: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement;
const inputFocused =
activeElement &&
(activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.tagName === 'SELECT' ||
activeElement.isContentEditable);
if (event.code === 'Escape' && settings.escExitFullscreen) {
exitFullscreen();
}
if (event.code === 'KeyF' && !inputFocused) {
toggleFullscreen();
}
if (event.code === 'F11' && shell.active) {
toggleFullscreen();
}
};
shell.on('win-visibility-changed', onWindowVisibilityChanged);
document.addEventListener('keydown', onKeyDown);
document.addEventListener('fullscreenchange', onFullscreenChange);
return () => {
shell.off('win-visibility-changed', onWindowVisibilityChanged);
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('fullscreenchange', onFullscreenChange);
};
}, [settings.escExitFullscreen, toggleFullscreen]);
return [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen];
};
export default useFullscreen;

26
src/common/useInterval.ts Normal file
View file

@ -0,0 +1,26 @@
import { useEffect, useRef } from 'react';
const useInterval = (duration: number) => {
const interval = useRef<NodeJS.Timer | null>(null);
const start = (callback: () => void) => {
cancel();
interval.current = setInterval(callback, duration);
};
const cancel = () => {
interval.current && clearInterval(interval.current);
interval.current = null;
};
useEffect(() => {
return () => cancel();
}, []);
return {
start,
cancel,
};
};
export default useInterval;

View file

@ -0,0 +1,38 @@
import { useMemo } from 'react';
import interfaceLanguages from 'stremio/common/interfaceLanguages.json';
const useLanguageSorting = (options: MultiselectMenuOption[]) => {
const userLangCode = useMemo(() => {
const lang = interfaceLanguages.find((l) => l.codes.includes(navigator.language || 'en-US'));
if (lang) {
const threeLetter = lang.codes[1] || 'eng';
const fullLocale = navigator.language || 'en-US';
return [threeLetter, fullLocale];
}
return ['eng'];
}, []);
const isLanguageDropdown = useMemo(() => {
return options?.some((opt) => interfaceLanguages.some((l) => l.name === opt.label));
}, [options]);
const sortedOptions = useMemo(() => {
const matchingIndex = options.findIndex((opt) => {
const lang = interfaceLanguages.find((l) => l.name === opt.label);
return userLangCode.some((code) => lang?.codes.includes(code));
});
if (matchingIndex === -1) {
return [...options].sort((a, b) => a.label.localeCompare(b.label));
}
const matchingOption = options[matchingIndex];
const otherOptions = options.filter((_, idx) => idx !== matchingIndex).sort((a, b) => a.label.localeCompare(b.label));
return [matchingOption, ...otherOptions];
}, [options, userLangCode, isLanguageDropdown]);
return { userLangCode, isLanguageDropdown, sortedOptions };
};
export default useLanguageSorting;

View file

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

View file

@ -0,0 +1,34 @@
// Copyright (C) 2017-2025 Smart code 203358507
import { useState, useEffect, useMemo } from 'react';
type DeviceOrientation = 'landscape' | 'portrait';
const useOrientation = () => {
const [windowHeight, setWindowHeight] = useState(window.innerHeight);
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
const orientation: DeviceOrientation = useMemo(() => {
if (windowHeight > windowWidth) {
return 'portrait';
} else {
return 'landscape';
}
}, [windowWidth, windowHeight]);
useEffect(() => {
const handleResize = () => {
setWindowHeight(window.innerHeight);
setWindowWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [window.innerWidth, window.innerHeight]);
return orientation;
};
export default useOrientation;

View file

@ -1,13 +1,14 @@
// Copyright (C) 2017-2023 Smart code 203358507
// Copyright (C) 2017-2025 Smart code 203358507
const React = require('react');
const { useServices } = require('stremio/services');
const { useProfile } = require('stremio/common');
import { useCallback } from 'react';
import { useServices } from 'stremio/services';
import useProfile from './useProfile';
const useSettings = () => {
const useSettings = (): [Settings, (settings: Settings) => void] => {
const { core } = useServices();
const profile = useProfile();
const updateSettings = React.useCallback((settings) => {
const updateSettings = useCallback((settings: Settings) => {
core.transport.dispatch({
action: 'Ctx',
args: {
@ -19,7 +20,8 @@ const useSettings = () => {
}
});
}, [profile]);
return [profile.settings, updateSettings];
};
module.exports = useSettings;
export default useSettings;

105
src/common/useShell.ts Normal file
View file

@ -0,0 +1,105 @@
import { useEffect, useState } from 'react';
import EventEmitter from 'eventemitter3';
const SHELL_EVENT_OBJECT = 'transport';
const transport = globalThis?.chrome?.webview;
const events = new EventEmitter();
enum ShellEventType {
SIGNAL = 1,
INVOKE_METHOD = 6,
}
type ShellEvent = {
id: number;
type: ShellEventType;
object: string;
args: string[];
};
export type WindowVisibility = {
visible: boolean;
visibility: number;
isFullscreen: boolean;
};
export type WindowState = {
state: number;
};
const createId = () => Math.floor(Math.random() * 9999) + 1;
const useShell = () => {
const [windowClosed, setWindowClosed] = useState(false);
const [windowHidden, setWindowHidden] = useState(false);
const on = (name: string, listener: (arg: any) => void) => {
events.on(name, listener);
};
const off = (name: string, listener: (arg: any) => void) => {
events.off(name, listener);
};
const send = (method: string, ...args: (string | number | object)[]) => {
try {
transport?.postMessage(JSON.stringify({
id: createId(),
type: ShellEventType.INVOKE_METHOD,
object: SHELL_EVENT_OBJECT,
method: 'onEvent',
args: [method, ...args],
}));
} catch (e) {
console.error('Shell', 'Failed to send event', e);
}
};
useEffect(() => {
const onWindowVisibilityChanged = (data: WindowVisibility) => {
setWindowClosed(data.visible === false && data.visibility === 0);
};
const onWindowStateChanged = (data: WindowState) => {
setWindowHidden(data.state === 9);
};
on('win-visibility-changed', onWindowVisibilityChanged);
on('win-state-changed', onWindowStateChanged);
return () => {
off('win-visibility-changed', onWindowVisibilityChanged);
off('win-state-changed', onWindowStateChanged);
};
}, []);
useEffect(() => {
if (!transport) return;
const onMessage = ({ data }: { data: string }) => {
try {
const { type, args } = JSON.parse(data) as ShellEvent;
if (type === ShellEventType.SIGNAL) {
const [methodName, methodArg] = args;
events.emit(methodName, methodArg);
}
} catch (e) {
console.error('Shell', 'Failed to handle event', e);
}
};
transport.addEventListener('message', onMessage);
return () => transport.removeEventListener('message', onMessage);
}, []);
return {
active: !!transport,
send,
on,
off,
windowClosed,
windowHidden,
};
};
export default useShell;

26
src/common/useTimeout.ts Normal file
View file

@ -0,0 +1,26 @@
import { useEffect, useRef } from 'react';
const useTimeout = (duration: number) => {
const timeout = useRef<NodeJS.Timeout | null>(null);
const start = (callback: () => void) => {
cancel();
timeout.current = setTimeout(callback, duration);
};
const cancel = () => {
timeout.current && clearTimeout(timeout.current);
timeout.current = null;
};
useEffect(() => {
return () => cancel();
}, []);
return {
start,
cancel,
};
};
export default useTimeout;

View file

@ -1,6 +1,7 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const { useTranslation } = require('react-i18next');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { default: Icon } = require('@stremio/stremio-icons/react');
@ -8,6 +9,7 @@ const { default: Image } = require('stremio/components/Image');
const styles = require('./styles');
const AddonDetails = ({ className, id, name, version, logo, description, types, transportUrl, official }) => {
const { t } = useTranslation();
const renderLogoFallback = React.useCallback(() => (
<Icon className={styles['icon']} name={'addons'} />
), []);
@ -24,7 +26,7 @@ const AddonDetails = ({ className, id, name, version, logo, description, types,
<span className={styles['name']}>{typeof name === 'string' && name.length > 0 ? name : id}</span>
{
typeof version === 'string' && version.length > 0 ?
<span className={styles['version']}>v. {version}</span>
<span className={styles['version']}>{t('ADDON_VERSION_SHORT', {version})}</span>
:
null
}
@ -41,7 +43,7 @@ const AddonDetails = ({ className, id, name, version, logo, description, types,
{
typeof transportUrl === 'string' && transportUrl.length > 0 ?
<div className={styles['section-container']}>
<span className={styles['section-header']}>URL: </span>
<span className={styles['section-header']}>{`${t('URL')}:`}</span>
<span className={classnames(styles['section-label'], styles['transport-url-label'])}>{transportUrl}</span>
</div>
:
@ -50,7 +52,7 @@ const AddonDetails = ({ className, id, name, version, logo, description, types,
{
Array.isArray(types) && types.length > 0 ?
<div className={styles['section-container']}>
<span className={styles['section-header']}>Supported types: </span>
<span className={styles['section-header']}>{`${t('ADDON_SUPPORTED_TYPES')}:`} </span>
<span className={styles['section-label']}>
{
types.length === 1 ?
@ -66,7 +68,7 @@ const AddonDetails = ({ className, id, name, version, logo, description, types,
{
!official ?
<div className={styles['section-container']}>
<div className={classnames(styles['section-label'], styles['disclaimer-label'])}>Using third-party add-ons will always be subject to your responsibility and the governing law of the jurisdiction you are located.</div>
<div className={classnames(styles['section-label'], styles['disclaimer-label'])}>{t('ADDON_DISCLAIMER')}</div>
</div>
:
null

View file

@ -1,6 +1,7 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const { useTranslation } = require('react-i18next');
const PropTypes = require('prop-types');
const ModalDialog = require('stremio/components/ModalDialog');
const { withCoreSuspender } = require('stremio/common/CoreSuspender');
@ -43,13 +44,14 @@ function withRemoteAndLocalAddon(AddonDetails) {
}
const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
const { t } = useTranslation();
const { core } = useServices();
const platform = usePlatform();
const addonDetails = useAddonDetails(transportUrl);
const modalButtons = React.useMemo(() => {
const cancelButton = {
className: styles['cancel-button'],
label: 'Cancel',
label: t('BUTTON_CANCEL'),
props: {
onClick: (event) => {
if (typeof onCloseRequest === 'function') {
@ -67,7 +69,7 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
addonDetails.remoteAddon.content.content.manifest.behaviorHints.configurable ?
{
className: styles['configure-button'],
label: 'Configure',
label: t('ADDON_CONFIGURE'),
props: {
onClick: (event) => {
platform.openExternal(transportUrl.replace('manifest.json', 'configure'));
@ -86,7 +88,7 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
const toggleButton = addonDetails.localAddon !== null ?
{
className: styles['uninstall-button'],
label: 'Uninstall',
label: t('ADDON_UNINSTALL'),
props: {
onClick: (event) => {
core.transport.dispatch({
@ -113,7 +115,7 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
{
className: styles['install-button'],
label: 'Install',
label: t('ADDON_INSTALL'),
props: {
onClick: (event) => {
core.transport.dispatch({
@ -141,21 +143,21 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
return addonDetails.remoteAddon?.content.type === 'Ready' ? addonDetails.remoteAddon.content.content.manifest.background : null;
}, [addonDetails.remoteAddon]);
return (
<ModalDialog className={styles['addon-details-modal-container']} title={'Stremio addon'} buttons={modalButtons} background={modalBackground} onCloseRequest={onCloseRequest}>
<ModalDialog className={styles['addon-details-modal-container']} title={t('STREMIO_COMMUNITY_ADDON')} buttons={modalButtons} background={modalBackground} onCloseRequest={onCloseRequest}>
{
addonDetails.selected === null ?
<div className={styles['addon-details-message-container']}>
Loading addon manifest
{t('ADDON_LOADING_MANIFEST')}
</div>
:
addonDetails.remoteAddon === null || addonDetails.remoteAddon.content.type === 'Loading' ?
<div className={styles['addon-details-message-container']}>
Loading addon manifest from {addonDetails.selected.transportUrl}
{t('ADDON_LOADING_MANIFEST_FROM', { origin: addonDetails.selected.transportUrl})}
</div>
:
addonDetails.remoteAddon.content.type === 'Err' && addonDetails.localAddon === null ?
<div className={styles['addon-details-message-container']}>
Failed to get addon manifest from {addonDetails.selected.transportUrl}
{t('ADDON_LOADING_MANIFEST_FAILED', {origin: addonDetails.selected.transportUrl})}
<div>{addonDetails.remoteAddon.content.content.message}</div>
</div>
:
@ -174,17 +176,18 @@ AddonDetailsModal.propTypes = {
onCloseRequest: PropTypes.func
};
const AddonDetailsModalFallback = ({ onCloseRequest }) => (
<ModalDialog
const AddonDetailsModalFallback = ({ onCloseRequest }) => {
const { t } = useTranslation();
return <ModalDialog
className={styles['addon-details-modal-container']}
title={'Stremio addon'}
title={t('STREMIO_COMMUNITY_ADDON')}
onCloseRequest={onCloseRequest}
>
<div className={styles['addon-details-message-container']}>
Loading addon manifest
{t('ADDON_LOADING_MANIFEST')}
</div>
</ModalDialog>
);
</ModalDialog>;
};
AddonDetailsModalFallback.propTypes = AddonDetailsModal.propTypes;

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 {
display: none;
}
@ -101,7 +101,7 @@
@media only screen and (orientation: landscape) {
.bottom-sheet {
.container {
max-width: 90%;
max-width: calc(90% - var(--safe-area-inset-left) - var(--safe-area-inset-right));
}
}
}

View file

@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom';
import classNames from 'classnames';
import useBinaryState from 'stremio/common/useBinaryState';
import useOrientation from 'stremio/common/useOrientation';
import styles from './BottomSheet.less';
const CLOSE_THRESHOLD = 100;
@ -17,6 +18,7 @@ type Props = {
const BottomSheet = ({ children, title, show, onClose }: Props) => {
const containerRef = useRef<HTMLDivElement>(null);
const orientation = useOrientation();
const [startOffset, setStartOffset] = useState(0);
const [offset, setOffset] = useState(0);
@ -58,6 +60,10 @@ const BottomSheet = ({ children, title, show, onClose }: Props) => {
!opened && onClose();
}, [opened]);
useEffect(() => {
opened && close();
}, [orientation]);
return opened && createPortal((
<div className={styles['bottom-sheet']}>
<div className={styles['backdrop']} onClick={onCloseRequest} />

View file

@ -7,13 +7,17 @@ import styles from './Button.less';
type Props = {
className?: string,
style?: object,
href?: string,
target?: string
title?: string,
disabled?: boolean,
tabIndex?: number,
children: React.ReactNode,
onKeyDown?: (event: React.KeyboardEvent) => void,
onMouseDown?: (event: React.MouseEvent) => void,
onMouseUp?: (event: React.MouseEvent) => void,
onMouseLeave?: (event: React.MouseEvent) => void,
onLongPress?: () => void,
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void,
onDoubleClick?: () => void,

View file

@ -0,0 +1,84 @@
// Copyright (C) 2017-2025 Smart code 203358507
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
.checkbox {
display: flex;
align-items: center;
overflow: visible;
.label {
display: flex;
flex-direction: row;
align-items: center;
padding: 0.5rem 0;
cursor: pointer;
span {
font-size: 0.9rem;
color: var(--primary-foreground-color);
opacity: 0.6;
}
.link {
font-size: 0.9rem;
color: var(--primary-accent-color);
margin-left: 0.5rem;
&:hover {
text-decoration: underline;
}
}
}
.checkbox-container {
position: relative;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.3rem;
background-color: var(--overlay-color);
padding: 0.1rem;
display: flex;
flex: none;
margin: 0 1rem 0 0.3rem;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease-in-out;
cursor: pointer;
outline: none;
user-select: none;
outline-width: var(--focus-outline-size);
outline-color: @color-surface-light5;
outline-offset: 2px;
input[type='checkbox'] {
opacity: 0;
width: 0;
height: 0;
position: absolute;
cursor: pointer;
}
.checkbox-icon {
width: 100%;
height: 100%;
color: var(--primary-foreground-color);
}
&.disabled {
cursor: not-allowed;
}
&.error {
border-color: var(--color-trakt);
}
&.checked {
background-color: var(--primary-accent-color);
}
&:hover, &:focus {
outline-style: solid;
}
}
}

View file

@ -0,0 +1,96 @@
// Copyright (C) 2017-2025 Smart code 203358507
import React, { useCallback, ChangeEvent, KeyboardEvent, RefCallback } from 'react';
import classNames from 'classnames';
import styles from './Checkbox.less';
import Button from '../Button';
import Icon from '@stremio/stremio-icons/react';
type Props = {
ref?: RefCallback<HTMLInputElement>;
name: string;
disabled?: boolean;
checked?: boolean;
className?: string;
label?: string;
link?: string;
href?: string;
onChange?: (props: {
type: string;
checked: boolean;
reactEvent: KeyboardEvent<HTMLInputElement> | ChangeEvent<HTMLInputElement>;
nativeEvent: Event;
}) => void;
error?: string;
};
const Checkbox = React.forwardRef<HTMLInputElement, Props>(({ name, disabled, className, label, href, link, onChange, error, checked }, ref) => {
const handleSelect = useCallback((event: ChangeEvent<HTMLInputElement>) => {
if (!disabled && onChange) {
onChange({
type: 'select',
checked: event.target.checked,
reactEvent: event,
nativeEvent: event.nativeEvent,
});
}
}, [disabled, onChange]);
const onKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
if ((event.key === 'Enter' || event.key === ' ') && !disabled) {
onChange && onChange({
type: 'select',
checked: !checked,
reactEvent: event as KeyboardEvent<HTMLInputElement>,
nativeEvent: event.nativeEvent,
});
}
}, [disabled, checked, onChange]);
return (
<div className={classNames(styles['checkbox'], className)}>
<label className={styles['label']} htmlFor={name}>
<div
className={classNames(
styles['checkbox-container'],
{ [styles['checked']]: checked },
{ [styles['disabled']]: disabled },
{ [styles['error']]: error }
)}
role={'checkbox'}
tabIndex={disabled ? -1 : 0}
aria-checked={checked}
onKeyDown={onKeyDown}
>
<input
ref={ref}
id={name}
type={'checkbox'}
checked={checked}
disabled={disabled}
onChange={handleSelect}
className={styles['input']}
/>
{
checked ?
<Icon name={'checkmark'} className={styles['checkbox-icon']} />
: null
}
</div>
<div>
<span>{label}</span>
{
href && link ?
<Button className={styles['link']} href={href} target={'_blank'} tabIndex={-1}>
{link}
</Button>
: null
}
</div>
</label>
</div>
);
});
export default Checkbox;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2025 Smart code 203358507
import Checkbox from './Checkbox';
export default Checkbox;

View file

@ -1,74 +1,85 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const AColorPicker = require('a-color-picker');
const { useTranslation } = require('react-i18next');
const { Button, ModalDialog } = require('stremio/components');
const useBinaryState = require('stremio/common/useBinaryState');
const ColorPicker = require('./ColorPicker');
const styles = require('./styles');
import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import classnames from 'classnames';
import * as AColorPicker from 'a-color-picker';
import { useTranslation } from 'react-i18next';
import { Button } from 'stremio/components';
import ModalDialog from 'stremio/components/ModalDialog';
import useBinaryState from 'stremio/common/useBinaryState';
import ColorPicker from './ColorPicker';
import styles from './ColorInput.less';
const parseColor = (value) => {
const parseColor = (value: string) => {
const color = AColorPicker.parseColor(value, 'hexcss4');
return typeof color === 'string' ? color : '#ffffffff';
};
const ColorInput = ({ className, value, dataset, onChange, ...props }) => {
type Props = {
className: string,
value: string,
onChange?: (value: string) => void,
onClick?: (event: React.MouseEvent) => void,
};
const ColorInput = ({ className, value, onChange, ...props }: Props) => {
const { t } = useTranslation();
const [modalOpen, openModal, closeModal] = useBinaryState(false);
const [tempValue, setTempValue] = React.useState(() => {
const [tempValue, setTempValue] = useState(() => {
return parseColor(value);
});
const labelButtonStyle = React.useMemo(() => ({
const labelButtonStyle = useMemo(() => ({
backgroundColor: value
}), [value]);
const isTransparent = React.useMemo(() => {
const isTransparent = useMemo(() => {
return parseColor(value).endsWith('00');
}, [value]);
const labelButtonOnClick = React.useCallback((event) => {
const labelButtonOnClick = useCallback((event: React.MouseEvent) => {
if (typeof props.onClick === 'function') {
props.onClick(event);
}
// @ts-expect-error: Property 'openModalPrevented' does not exist on type 'MouseEvent'.
if (!event.nativeEvent.openModalPrevented) {
openModal();
}
}, [props.onClick]);
const modalDialogOnClick = React.useCallback((event) => {
const modalDialogOnClick = useCallback((event: React.MouseEvent) => {
// @ts-expect-error: Property 'openModalPrevented' does not exist on type 'MouseEvent'.
event.nativeEvent.openModalPrevented = true;
}, []);
const modalButtons = React.useMemo(() => {
const selectButtonOnClick = (event) => {
const modalButtons = useMemo(() => {
const selectButtonOnClick = () => {
if (typeof onChange === 'function') {
onChange({
type: 'change',
value: tempValue,
dataset: dataset,
reactEvent: event,
nativeEvent: event.nativeEvent
});
onChange(tempValue);
}
closeModal();
};
return [
{
label: 'Select',
label: t('SELECT'),
props: {
'data-autofocus': true,
onClick: selectButtonOnClick
}
}
];
}, [tempValue, dataset, onChange]);
const colorPickerOnInput = React.useCallback((event) => {
setTempValue(parseColor(event.value));
}, [tempValue, onChange]);
const colorPickerOnInput = useCallback((color: string) => {
setTempValue(parseColor(color));
}, []);
React.useLayoutEffect(() => {
useLayoutEffect(() => {
setTempValue(parseColor(value));
}, [value, modalOpen]);
return (
<Button title={isTransparent ? t('BUTTON_COLOR_TRANSPARENT') : value} {...props} style={labelButtonStyle} className={classnames(className, styles['color-input-container'])} onClick={labelButtonOnClick}>
{
@ -81,7 +92,7 @@ const ColorInput = ({ className, value, dataset, onChange, ...props }) => {
}
{
modalOpen ?
<ModalDialog title={'Choose a color:'} buttons={modalButtons} onCloseRequest={closeModal} onClick={modalDialogOnClick}>
<ModalDialog title={t('CHOOSE_COLOR')} buttons={modalButtons} onCloseRequest={closeModal} onClick={modalDialogOnClick}>
<ColorPicker className={styles['color-picker-container']} value={tempValue} onInput={colorPickerOnInput} />
</ModalDialog>
:
@ -91,12 +102,4 @@ const ColorInput = ({ className, value, dataset, onChange, ...props }) => {
);
};
ColorInput.propTypes = {
className: PropTypes.string,
value: PropTypes.string,
dataset: PropTypes.object,
onChange: PropTypes.func,
onClick: PropTypes.func
};
module.exports = ColorInput;
export default ColorInput;

View file

@ -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;
}
@ -29,10 +29,7 @@ const ColorPicker = ({ className, value, onInput }) => {
React.useLayoutEffect(() => {
if (typeof onInput === 'function') {
pickerRef.current.on('change', (picker, value) => {
onInput({
type: 'input',
value: parseColor(value)
});
onInput(parseColor(value));
});
}
return () => {

View file

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

View file

@ -1,6 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
const ColorInput = require('./ColorInput');
module.exports = ColorInput;

View file

@ -0,0 +1,6 @@
// Copyright (C) 2017-2023 Smart code 203358507
import ColorInput from './ColorInput';
export default ColorInput;

View file

@ -0,0 +1,17 @@
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
.context-menu-container {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
.context-menu {
position: fixed;
border-radius: var(--border-radius);
background-color: var(--modal-background-color);
box-shadow: 0 1.35rem 2.7rem @color-background-dark5-40,
0 1.1rem 0.85rem @color-background-dark5-20;
}
}

View file

@ -0,0 +1,101 @@
import React, { memo, RefObject, useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import styles from './ContextMenu.less';
const PADDING = 8;
type Coordinates = [number, number];
type Size = [number, number];
type Props = {
children: React.ReactNode,
on: RefObject<HTMLElement>[],
autoClose: boolean,
};
const ContextMenu = ({ children, on, autoClose }: Props) => {
const [active, setActive] = useState(false);
const [position, setPosition] = useState<Coordinates>([0, 0]);
const [containerSize, setContainerSize] = useState<Size>([0, 0]);
const ref = useCallback((element: HTMLDivElement) => {
element && setContainerSize([element.offsetWidth, element.offsetHeight]);
}, []);
const style = useMemo(() => {
const [viewportWidth, viewportHeight] = [window.innerWidth, window.innerHeight];
const [containerWidth, containerHeight] = containerSize;
const [x, y] = position;
const left = Math.max(
PADDING,
Math.min(
x + containerWidth > viewportWidth - PADDING ? x - containerWidth : x,
viewportWidth - containerWidth - PADDING
)
);
const top = Math.max(
PADDING,
Math.min(
y + containerHeight > viewportHeight - PADDING ? y - containerHeight : y,
viewportHeight - containerHeight - PADDING
)
);
return { top, left };
}, [position, containerSize]);
const close = () => {
setPosition([0, 0]);
setActive(false);
};
const stopPropagation = (event: React.MouseEvent | React.TouchEvent) => {
event.stopPropagation();
};
const onContextMenu = (event: MouseEvent) => {
event.preventDefault();
setPosition([event.clientX, event.clientY]);
setActive(true);
};
const handleKeyDown = useCallback((event: KeyboardEvent) => event.key === 'Escape' && close(), []);
const onClick = useCallback(() => {
autoClose && close();
}, [autoClose]);
useEffect(() => {
on.forEach((ref) => ref.current && ref.current.addEventListener('contextmenu', onContextMenu));
document.addEventListener('keydown', handleKeyDown);
return () => {
on.forEach((ref) => ref.current && ref.current.removeEventListener('contextmenu', onContextMenu));
document.removeEventListener('keydown', handleKeyDown);
};
}, [on]);
return active && createPortal((
<div
className={styles['context-menu-container']}
onMouseDown={close}
onTouchStart={close}
>
<div
ref={ref}
className={styles['context-menu']}
style={style}
onMouseDown={stopPropagation}
onTouchStart={stopPropagation}
onClick={onClick}
>
{children}
</div>
</div>
), document.body);
};
export default memo(ContextMenu);

View file

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

View file

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

View file

@ -2,7 +2,8 @@
const React = require('react');
const { useTranslation } = require('react-i18next');
const { Button, ModalDialog } = require('stremio/components');
const { default: Button } = require('stremio/components/Button');
const ModalDialog = require('stremio/components/ModalDialog');
const useEvents = require('./useEvents');
const styles = require('./styles');
const { default: Icon } = require('@stremio/stremio-icons/react');

View file

@ -6,9 +6,9 @@
position: relative;
z-index: 0;
overflow: clip;
margin-left: env(safe-area-inset-left, 0px);
margin-right: env(safe-area-inset-right, 0px);
width: calc(100% - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px));
margin-left: var(--safe-area-inset-left);
margin-right: var(--safe-area-inset-right);
width: calc(100% - var(--safe-area-inset-left) - var(--safe-area-inset-right));
height: 100%;
.horizontal-nav-bar {
@ -22,20 +22,19 @@
.vertical-nav-bar {
position: absolute;
top: var(--horizontal-nav-bar-size);
bottom: var(--calculated-bottom-safe-inset);
bottom: 0;
left: 0;
z-index: 1;
}
.nav-content-container {
position: absolute;
padding-top: calc(var(--horizontal-nav-bar-size) + env(safe-area-inset-top, 0px));
top: 0;
top: calc(var(--horizontal-nav-bar-size) + var(--safe-area-inset-top));
right: 0;
bottom: 0;
left: var(--vertical-nav-bar-size);
z-index: 0;
overflow: scroll;
overflow: hidden;
}
}
@ -43,7 +42,7 @@
.main-nav-bars-container {
.nav-content-container {
left: 0;
padding-bottom: var(--vertical-nav-bar-size);
bottom: var(--vertical-nav-bar-size);
}
.vertical-nav-bar {

View file

@ -51,14 +51,30 @@
}
}
@media @phone-landscape {
.action-button-container {
.label-container {
display: none;
}
}
}
@media only screen and (max-width: @minimum) {
.action-button-container {
flex-direction: row;
padding: 0 1rem;
.icon-container {
height: 2rem;
width: 2rem;
.icon {
width: 2rem;
height: 2rem;
}
}
.label-container {
display: none;
}
}
}

View file

@ -17,6 +17,7 @@ const ActionButton = require('./ActionButton');
const MetaLinks = require('./MetaLinks');
const MetaPreviewPlaceholder = require('./MetaPreviewPlaceholder');
const styles = require('./styles');
const { Ratings } = require('./Ratings');
const ALLOWED_LINK_REDIRECTS = [
routesRegexp.search.regexp,
@ -24,7 +25,7 @@ const ALLOWED_LINK_REDIRECTS = [
routesRegexp.metadetails.regexp
];
const MetaPreview = ({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary }) => {
const MetaPreview = React.forwardRef(({ className, compact, name, logo, background, runtime, releaseInfo, released, description, deepLinks, links, trailerStreams, inLibrary, toggleInLibrary, ratingInfo }, ref) => {
const { t } = useTranslation();
const [shareModalOpen, openShareModal, closeShareModal] = useBinaryState(false);
const linksGroups = React.useMemo(() => {
@ -98,7 +99,7 @@ const MetaPreview = ({ className, compact, name, logo, background, runtime, rele
<div className={styles['logo-placeholder']}>{name}</div>
), [name]);
return (
<div className={classnames(className, styles['meta-preview-container'], { [styles['compact']]: compact })}>
<div className={classnames(className, styles['meta-preview-container'], { [styles['compact']]: compact })} ref={ref}>
{
typeof background === 'string' && background.length > 0 ?
<div className={styles['background-image-layer']}>
@ -232,6 +233,15 @@ const MetaPreview = ({ className, compact, name, logo, background, runtime, rele
:
null
}
{
!compact && ratingInfo !== null ?
<Ratings
ratingInfo={ratingInfo}
className={styles['ratings']}
/>
:
null
}
{
linksGroups.has(CONSTANTS.SHARE_LINK_CATEGORY) && !compact ?
<React.Fragment>
@ -261,7 +271,7 @@ const MetaPreview = ({ className, compact, name, logo, background, runtime, rele
</div>
</div>
);
};
});
MetaPreview.Placeholder = MetaPreviewPlaceholder;
@ -287,7 +297,8 @@ MetaPreview.propTypes = {
})),
trailerStreams: PropTypes.array,
inLibrary: PropTypes.bool,
toggleInLibrary: PropTypes.func
toggleInLibrary: PropTypes.func,
ratingInfo: PropTypes.object,
};
module.exports = MetaPreview;

View file

@ -0,0 +1,61 @@
// Copyright (C) 2017-2025 Smart code 203358507
@import (reference) '~stremio/common/screen-sizes.less';
@height: 4rem;
@width: 4rem;
@height-mobile: 3rem;
@width-mobile: 3rem;
.ratings-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
background-color: var(--overlay-color);
border-radius: 2rem;
height: @height;
width: fit-content;
.icon-container {
display: flex;
justify-content: center;
align-items: center;
height: @height;
width: @width;
padding: 0 1rem;
cursor: pointer;
.icon {
width: calc(@width / 2);
height: calc(@height / 2);
color: var(--primary-foreground-color);
opacity: 0.7;
&:hover {
opacity: 1;
}
}
&.disabled {
pointer-events: none;
}
}
}
@media @phone-landscape {
.ratings-container {
height: @height-mobile;
.icon-container {
height: @height-mobile;
width: @width-mobile;
.icon {
width: 1.75rem;
height: 1.75rem;
}
}
}
}

View file

@ -0,0 +1,31 @@
// Copyright (C) 2017-2025 Smart code 203358507
import React, { useMemo } from 'react';
import useRating from './useRating';
import styles from './Ratings.less';
import Icon from '@stremio/stremio-icons/react';
import classNames from 'classnames';
type Props = {
metaId?: string;
ratingInfo?: Loadable<RatingInfo>;
className?: string;
};
const Ratings = ({ ratingInfo, className }: Props) => {
const { onLiked, onLoved, liked, loved } = useRating(ratingInfo);
const disabled = useMemo(() => ratingInfo?.type !== 'Ready', [ratingInfo]);
return (
<div className={classNames(styles['ratings-container'], className)}>
<div className={classNames(styles['icon-container'], { [styles['disabled']]: disabled })} onClick={onLiked}>
<Icon name={liked ? 'thumbs-up' : 'thumbs-up-outline'} className={styles['icon']} />
</div>
<div className={classNames(styles['icon-container'], { [styles['disabled']]: disabled })} onClick={onLoved}>
<Icon name={loved ? 'heart' : 'heart-outline'} className={styles['icon']} />
</div>
</div>
);
};
export default Ratings;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2025 Smart code 203358507
import Ratings from './Ratings';
export { Ratings };

View file

@ -0,0 +1,48 @@
// Copyright (C) 2017-2025 Smart code 203358507
import { useMemo, useCallback } from 'react';
import { useServices } from 'stremio/services';
const useRating = (ratingInfo?: Loadable<RatingInfo>) => {
const { core } = useServices();
const setRating = useCallback((status: Rating) => {
core.transport.dispatch({
action: 'MetaDetails',
args: {
action: 'Rate',
args: status,
},
});
}, []);
const status = useMemo(() => {
const content = ratingInfo?.type === 'Ready' ? ratingInfo.content as RatingInfo : null;
return content?.status;
}, [ratingInfo]);
const liked = useMemo(() => {
return status === 'liked';
}, [status]);
const loved = useMemo(() => {
return status === 'loved';
}, [status]);
const onLiked = useCallback(() => {
setRating(status === 'liked' ? null : 'liked');
}, [status]);
const onLoved = useCallback(() => {
setRating(status === 'loved' ? null : 'loved');
}, [status]);
return {
onLiked,
onLoved,
liked,
loved,
};
};
export default useRating;

View file

@ -107,7 +107,9 @@
display: flex;
flex-direction: row;
align-items: center;
border-radius: 2.5rem;
border-radius: 0.5rem;
border: var(--focus-outline-size) solid transparent;
padding: 0rem 0.5rem;
&:focus {
outline: none;
@ -157,7 +159,6 @@
display: flex;
flex-direction: row;
align-items: flex-end;
max-height: 15rem;
flex-wrap: wrap;
padding-top: 3.5rem;
overflow: visible;
@ -207,12 +208,45 @@
}
}
}
.ratings {
margin-bottom: 1rem;
margin-right: 1rem;
}
}
.share-prompt {
width: 30rem;
}
@media @phone-landscape {
.meta-preview-container {
.meta-info-container {
.logo {
height: 5rem;
margin-bottom: 1rem;
}
}
.action-buttons-container {
padding-top: 1.5rem;
gap: 0.5rem;
.action-button {
padding: 0 1.5rem !important;
margin-right: 0rem !important;
height: 3rem;
border-radius: 2rem;
}
}
.ratings {
margin-right: 0;
}
}
}
@media only screen and (max-width: @minimum) {
.meta-preview-container {
.meta-info-container {
@ -229,16 +263,16 @@
}
}
.action-buttons-container {
flex-shrink: 0;
margin-top: 3rem;
overflow: visible;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
.action-buttons-container {
flex-shrink: 0;
margin-top: 3rem;
overflow: visible;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}
.share-prompt {

View file

@ -1,6 +1,7 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const { useTranslation } = require('react-i18next');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { useRouteFocused, useModalsContainer } = require('stremio-router');
@ -10,6 +11,7 @@ const { Modal } = require('stremio-router');
const styles = require('./styles');
const ModalDialog = ({ className, title, buttons, children, dataset, onCloseRequest, background, ...props }) => {
const { t } = useTranslation();
const routeFocused = useRouteFocused();
const modalsContainer = useModalsContainer();
const modalContainerRef = React.useRef(null);
@ -60,7 +62,7 @@ const ModalDialog = ({ className, title, buttons, children, dataset, onCloseRequ
<Modal ref={modalContainerRef} {...props} className={classnames(className, styles['modal-container'])} onMouseDown={onModalContainerMouseDown}>
<div className={styles['modal-dialog-container']} onMouseDown={onModalDialogContainerMouseDown}>
<div className={styles['modal-dialog-background']} style={{backgroundImage: `url('${background}')`}} />
<Button className={styles['close-button-container']} title={'Close'} onClick={closeButtonOnClick}>
<Button className={styles['close-button-container']} title={t('BUTTON_CLOSE')} onClick={closeButtonOnClick}>
<Icon className={styles['icon']} name={'close'} />
</Button>
<div className={styles['modal-dialog-content']}>

View file

@ -1,6 +1,7 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const { useTranslation } = require('react-i18next');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { default: Icon } = require('@stremio/stremio-icons/react');
@ -10,16 +11,17 @@ const ModalDialog = require('stremio/components/ModalDialog');
const useBinaryState = require('stremio/common/useBinaryState');
const styles = require('./styles');
const Multiselect = ({ className, mode, direction, title, disabled, dataset, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => {
const Multiselect = ({ className, mode, direction, title, disabled, dataset, options, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => {
const { t } = useTranslation();
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
const options = React.useMemo(() => {
return Array.isArray(props.options) ?
props.options.filter((option) => {
const filteredOptions = React.useMemo(() => {
return Array.isArray(options) ?
options.filter((option) => {
return option && (typeof option.value === 'string' || option.value === null);
})
:
[];
}, [props.options]);
}, [options]);
const selected = React.useMemo(() => {
return Array.isArray(props.selected) ?
props.selected.filter((value) => {
@ -94,7 +96,7 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, ren
:
selected.length > 0 ?
selected.map((value) => {
const option = options.find((option) => option.value === value);
const option = filteredOptions.find((option) => option.value === value);
return option && typeof option.label === 'string' ?
option.label
:
@ -109,12 +111,12 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, ren
}
{children}
</Button>
), [menuOpen, title, disabled, options, selected, labelOnClick, renderLabelContent, renderLabelText]);
), [menuOpen, title, disabled, filteredOptions, selected, labelOnClick, renderLabelContent, renderLabelText]);
const renderMenu = React.useCallback(() => (
<div className={styles['menu-container']} onKeyDown={menuOnKeyDown} onClick={menuOnClick}>
{
options.length > 0 ?
options.map(({ label, title, value }) => (
filteredOptions.length > 0 ?
filteredOptions.map(({ label, title, value }) => (
<Button key={value} className={classnames(styles['option-container'], { 'selected': selected.includes(value) })} title={typeof title === 'string' ? title : typeof label === 'string' ? label : value} data-value={value} onClick={optionOnClick}>
<div className={styles['label']}>{typeof label === 'string' ? label : value}</div>
<div className={styles['icon']} />
@ -122,11 +124,11 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, ren
))
:
<div className={styles['no-options-container']}>
<div className={styles['label']}>No options available</div>
<div className={styles['label']}>{t('NO_OPTIONS')}</div>
</div>
}
</div>
), [options, selected, menuOnKeyDown, menuOnClick, optionOnClick]);
), [filteredOptions, selected, menuOnKeyDown, menuOnClick, optionOnClick]);
const renderPopupLabel = React.useMemo(() => (labelProps) => {
return renderLabel({
...labelProps,

View file

@ -50,7 +50,7 @@
.modal-container, .popup-menu-container {
.menu-container {
max-height: calc(3.2rem * 7);
max-height: calc(3rem * 7);
.option-container {
display: flex;

View file

@ -2,7 +2,8 @@
@import (reference) '~stremio/common/screen-sizes.less';
@parent-height: 10rem;
@parent-height: 12rem;
@item-height: 3rem;
.dropdown {
background: var(--modal-background-color);
@ -18,7 +19,7 @@
&.open {
display: block;
max-height: calc(3.3rem * 7);
max-height: calc(@item-height * 7);
overflow: auto;
}

View file

@ -10,23 +10,25 @@ import styles from './Dropdown.less';
type Props = {
options: MultiselectMenuOption[];
selectedOption?: MultiselectMenuOption | null;
value?: any;
menuOpen: boolean | (() => void);
level: number;
setLevel: (level: number) => void;
onSelect: (value: number) => void;
onSelect: (value: any) => void;
};
const Dropdown = ({ level, setLevel, options, onSelect, selectedOption, menuOpen }: Props) => {
const Dropdown = ({ level, setLevel, options, onSelect, value, menuOpen }: Props) => {
const { t } = useTranslation();
const optionsRef = useRef(new Map());
const containerRef = useRef(null);
const handleSetOptionRef = useCallback((value: number) => (node: HTMLButtonElement | null) => {
const selectedOption = options.find((opt) => opt.value === value);
const handleSetOptionRef = useCallback((optionValue: any) => (node: HTMLButtonElement | null) => {
if (node) {
optionsRef.current.set(value, node);
optionsRef.current.set(optionValue, node);
} else {
optionsRef.current.delete(value);
optionsRef.current.delete(optionValue);
}
}, []);
@ -63,11 +65,11 @@ const Dropdown = ({ level, setLevel, options, onSelect, selectedOption, menuOpen
.filter((option: MultiselectMenuOption) => !option.hidden)
.map((option: MultiselectMenuOption) => (
<Option
key={option.id}
key={option.value}
ref={handleSetOptionRef(option.value)}
option={option}
onSelect={onSelect}
selectedOption={selectedOption}
selectedValue={value}
/>
))
}

View file

@ -1,6 +1,9 @@
// Copyright (C) 2017-2024 Smart code 203358507
@height: 3rem;
.option {
height: @height;
font-size: var(--font-size-normal);
color: var(--primary-foreground-color);
align-items: center;

View file

@ -8,13 +8,12 @@ import Icon from '@stremio/stremio-icons/react';
type Props = {
option: MultiselectMenuOption;
selectedOption?: MultiselectMenuOption | null;
onSelect: (value: number) => void;
selectedValue?: any;
onSelect: (value: any) => void;
};
const Option = forwardRef<HTMLButtonElement, Props>(({ option, selectedOption, onSelect }, ref) => {
// consider using option.id === selectedOption?.id instead
const selected = useMemo(() => option?.value === selectedOption?.value, [option, selectedOption]);
const Option = forwardRef<HTMLButtonElement, Props>(({ option, selectedValue, onSelect }, ref) => {
const selected = useMemo(() => option?.value === selectedValue, [option, selectedValue]);
const handleClick = useCallback(() => {
onSelect(option.value);

View file

@ -1,6 +1,7 @@
// Copyright (C) 2017-2024 Smart code 203358507
@border-radius: 2.75rem;
@height: 3rem;
.multiselect-menu {
position: relative;
@ -14,14 +15,22 @@
}
.multiselect-button {
color: var(--primary-foreground-color);
height: @height;
padding: 0.75rem 1.5rem;
display: flex;
flex: 1;
justify-content: space-between;
align-items: center;
gap: 0 0.5rem;
border-radius: @border-radius;
.label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--primary-foreground-color);
}
.icon {
width: 1rem;
color: var(--primary-foreground-color);
@ -33,7 +42,7 @@
}
}
&:hover {
&:hover, &.active {
background-color: var(--overlay-color);
}
}

View file

@ -11,31 +11,41 @@ import useOutsideClick from 'stremio/common/useOutsideClick';
type Props = {
className?: string,
title?: string;
title?: string | (() => string | null);
options: MultiselectMenuOption[];
selectedOption?: MultiselectMenuOption;
onSelect: (value: number) => void;
value?: any;
disabled?: boolean,
onSelect: (value: any) => void;
};
const MultiselectMenu = ({ className, title, options, selectedOption, onSelect }: Props) => {
const MultiselectMenu = ({ className, title, options, value, disabled, onSelect }: Props) => {
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
const multiselectMenuRef = useOutsideClick(() => closeMenu());
const [level, setLevel] = React.useState<number>(0);
const onOptionSelect = (value: number) => {
level ? setLevel(level + 1) : onSelect(value), closeMenu();
const selectedOption = options.find((opt) => opt.value === value);
const onOptionSelect = (selectedValue: string | number) => {
level ? setLevel(level + 1) : onSelect(selectedValue), closeMenu();
};
return (
<div className={classNames(styles['multiselect-menu'], className)} ref={multiselectMenuRef}>
<div className={classNames(styles['multiselect-menu'], { [styles['active']]: menuOpen }, className)} ref={multiselectMenuRef}>
<Button
className={classNames(styles['multiselect-button'], { [styles['open']]: menuOpen })}
disabled={disabled}
onClick={toggleMenu}
tabIndex={0}
aria-haspopup='listbox'
aria-expanded={menuOpen}
>
{title}
<div className={styles['label']}>
{
typeof title === 'function'
? title()
: title ?? selectedOption?.label
}
</div>
<Icon name={'caret-down'} className={classNames(styles['icon'], { [styles['open']]: menuOpen })} />
</Button>
{
@ -46,7 +56,7 @@ const MultiselectMenu = ({ className, title, options, selectedOption, onSelect }
options={options}
onSelect={onOptionSelect}
menuOpen={menuOpen}
selectedOption={selectedOption}
value={value}
/>
: null
}

View file

@ -1,7 +1,7 @@
type MultiselectMenuOption = {
id?: number;
label: string;
value: number;
value: string | number | null;
destination?: string;
default?: boolean;
hidden?: boolean;

View file

@ -5,7 +5,7 @@ const PropTypes = require('prop-types');
const classnames = require('classnames');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { Button, Image } = require('stremio/components');
const useFullscreen = require('stremio/common/useFullscreen');
const { default: useFullscreen } = require('stremio/common/useFullscreen');
const usePWA = require('stremio/common/usePWA');
const SearchBar = require('./SearchBar');
const NavMenu = require('./NavMenu');

View file

@ -7,20 +7,28 @@ const { useTranslation } = require('react-i18next');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { useServices } = require('stremio/services');
const { Button } = require('stremio/components');
const useFullscreen = require('stremio/common/useFullscreen');
const { default: useFullscreen } = require('stremio/common/useFullscreen');
const useProfile = require('stremio/common/useProfile');
const usePWA = require('stremio/common/usePWA');
const useTorrent = require('stremio/common/useTorrent');
const { withCoreSuspender } = require('stremio/common/CoreSuspender');
const useStreamingServer = require('stremio/common/useStreamingServer');
const styles = require('./styles');
const NavMenuContent = ({ onClick }) => {
const { t } = useTranslation();
const { core } = useServices();
const profile = useProfile();
const streamingServer = useStreamingServer();
const { createTorrentFromMagnet } = useTorrent();
const [fullscreen, requestFullscreen, exitFullscreen] = useFullscreen();
const [isIOSPWA, isAndroidPWA] = usePWA();
const streamingServerWarningDismissed = React.useMemo(() => {
return streamingServer.settings !== null && streamingServer.settings.type === 'Ready' || (
!isNaN(profile.settings.streamingServerWarningDismissed.getTime()) &&
profile.settings.streamingServerWarningDismissed.getTime() > Date.now()
);
}, [profile.settings, streamingServer.settings]);
const logoutButtonOnClick = React.useCallback(() => {
core.transport.dispatch({
action: 'Ctx',
@ -38,7 +46,7 @@ const NavMenuContent = ({ onClick }) => {
}
}, []);
return (
<div className={classnames(styles['nav-menu-container'], 'animation-fade-in')} onClick={onClick}>
<div className={classnames(styles['nav-menu-container'], 'animation-fade-in', { [styles['with-warning']]: !streamingServerWarningDismissed } )} onClick={onClick}>
<div className={styles['user-info-container']}>
<div
className={styles['avatar-container']}

View file

@ -7,6 +7,9 @@
popup-menu-container: menu-container;
}
@mobile-height: calc(var(--small-viewport-height) - var(--horizontal-nav-bar-size) - var(--vertical-nav-bar-size));
@height: calc(var(--small-viewport-height) - var(--horizontal-nav-bar-size));
.nav-menu-popup-label {
.popup-menu-container {
margin-top: 1rem;
@ -14,11 +17,15 @@
}
.nav-menu-container {
width: 22rem;
max-height: calc(100vh - var(--horizontal-nav-bar-size) - 1rem);
max-height: calc(@height - 1rem);
overflow-y: auto;
border-radius: var(--border-radius);
background-color: var(--modal-background-color);
&.with-warning {
max-height: calc(@height - 6rem);
}
.user-info-container {
display: flex;
padding: 1.5rem 1rem;
@ -108,6 +115,10 @@
@media only screen and (max-width: @minimum) {
.nav-menu-container {
max-height: calc(100vh - var(--horizontal-nav-bar-size) - var(--vertical-nav-bar-size) - 1rem);
max-height: calc(@mobile-height - 1rem);
&.with-warning {
max-height: calc(@mobile-height - 8.5rem);
}
}
}

View file

@ -7,7 +7,8 @@ const debounce = require('lodash.debounce');
const { useTranslation } = require('react-i18next');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { useRouteFocused } = require('stremio-router');
const { Button, TextInput } = require('stremio/components');
const Button = require('stremio/components/Button').default;
const TextInput = require('stremio/components/TextInput').default;
const useTorrent = require('stremio/common/useTorrent');
const { withCoreSuspender } = require('stremio/common/CoreSuspender');
const useSearchHistory = require('./useSearchHistory');

View file

@ -69,7 +69,7 @@
width: 3.5rem;
height: 3.5rem;
border-radius: 0.75rem;
opacity: 0.4;
opacity: 0.6;
.icon {
flex: none;
@ -79,7 +79,7 @@
opacity: 0.6;
}
&:hover, &:global(.active) {
.active() {
background-color: var(--overlay-color);
opacity: 1;
@ -88,6 +88,16 @@
opacity: 0.8;
}
}
&:global(.active) {
.active();
}
@media (pointer: fine) {
&:hover {
.active();
}
}
}
}

View file

@ -18,12 +18,21 @@
.label {
opacity: 0.6;
}
&:global(.selected) {
.label {
opacity: 1;
}
}
}
}
&:global(.selected) {
.icon {
opacity: 1;
}
.icon, .label {
color: var(--primary-accent-color);
}
}
@ -37,7 +46,7 @@
.icon {
color: var(--primary-foreground-color);
opacity: 0.2;
opacity: 0.35;
}
.label {
@ -61,12 +70,12 @@
@media only screen and (max-width: @minimum) {
.nav-tab-button-container {
.label {
opacity: 0.2;
opacity: 0.6;
}
&:global(.selected) {
.label {
opacity: 0.6;
opacity: 1;
}
}
}

View file

@ -13,6 +13,7 @@
background-color: transparent;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
@ -21,6 +22,7 @@
.nav-tab-button {
width: calc(var(--vertical-nav-bar-size) - 1.2rem);
height: calc(var(--vertical-nav-bar-size) - 1.2rem);
min-height: 3.5rem;
}
}

View file

@ -0,0 +1,65 @@
// Copyright (C) 2017-2025 Smart code 203358507
.number-input {
user-select: text;
display: flex;
max-width: 14rem;
height: 3.5rem;
margin-bottom: 1rem;
color: var(--primary-foreground-color);
background: var(--overlay-color);
border-radius: 3.5rem;
.button {
flex: none;
width: 3.5rem;
height: 3.5rem;
padding: 1rem;
background: var(--overlay-color);
border: none;
border-radius: 100%;
cursor: pointer;
z-index: 1;
.icon {
width: 100%;
height: 100%;
}
}
.number-display {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 0 1rem;
&::-moz-focus-inner {
border: none;
}
.label {
font-size: 0.8rem;
font-weight: 400;
opacity: 0.7;
}
.value {
font-size: 1.2rem;
display: flex;
justify-content: center;
width: 100%;
color: var(--primary-foreground-color);
text-align: center;
appearance: none;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
}
}

View file

@ -0,0 +1,113 @@
// Copyright (C) 2017-2025 Smart code 203358507
import Icon from '@stremio/stremio-icons/react';
import React, { ChangeEvent, forwardRef, memo, useCallback, useState } from 'react';
import { type KeyboardEvent, type InputHTMLAttributes } from 'react';
import classnames from 'classnames';
import styles from './NumberInput.less';
import Button from '../Button';
type Props = InputHTMLAttributes<HTMLInputElement> & {
containerClassName?: string;
className?: string;
disabled?: boolean;
showButtons?: boolean;
defaultValue?: number;
label?: string;
min?: number;
max?: number;
value?: number;
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
onSubmit?: (event: KeyboardEvent<HTMLInputElement>) => void;
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
};
const NumberInput = forwardRef<HTMLInputElement, Props>(({ defaultValue = 0, showButtons, onKeyDown, onSubmit, min, max, onChange, ...props }, ref) => {
const [value, setValue] = useState(defaultValue);
const displayValue = props.value ?? value;
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(event);
if (event.key === 'Enter') {
onSubmit?.(event);
}
}, [onKeyDown, onSubmit]);
const handleValueChange = (newValue: number) => {
if (props.value === undefined) {
setValue(newValue);
}
onChange?.({ target: { value: newValue.toString() }} as ChangeEvent<HTMLInputElement>);
};
const handleIncrement = () => {
handleValueChange(clampValueToRange((displayValue || 0) + 1));
};
const handleDecrement = () => {
handleValueChange(clampValueToRange((displayValue || 0) - 1));
};
const clampValueToRange = (value: number): number => {
const minValue = min ?? 0;
if (value < minValue) {
return minValue;
}
if (max !== undefined && value > max) {
return max;
}
return value;
};
const handleInputChange = useCallback(({ target: { valueAsNumber }}: ChangeEvent<HTMLInputElement>) => {
handleValueChange(clampValueToRange(valueAsNumber || 0));
}, []);
return (
<div className={classnames(props.containerClassName, styles['number-input'])}>
{
showButtons ?
<Button
className={styles['button']}
onClick={handleDecrement}
disabled={props.disabled || (min !== undefined ? displayValue <= min : false)}>
<Icon className={styles['icon']} name={'remove'} />
</Button>
: null
}
<div className={classnames(styles['number-display'], { [styles['buttons-container']]: showButtons })}>
{
props.label ?
<div className={styles['label']}>{props.label}</div>
: null
}
<input
ref={ref}
type={'number'}
tabIndex={0}
value={displayValue}
{...props}
className={classnames(props.className, styles['value'], { [styles.disabled]: props.disabled })}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
/>
</div>
{
showButtons ?
<Button
className={styles['button']} onClick={handleIncrement} disabled={props.disabled || (max !== undefined ? displayValue >= max : false)}>
<Icon className={styles['icon']} name={'add'} />
</Button>
: null
}
</div>
);
});
NumberInput.displayName = 'NumberInput';
export default memo(NumberInput);

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2025 Smart code 203358507
import NumberInput from './NumberInput';
export default NumberInput;

View file

@ -70,7 +70,7 @@ const SharePrompt = ({ className, url }) => {
onClick={selectInputContent}
tabIndex={-1}
/>
<Button className={styles['copy-button']} title={'Copy to clipboard'} onClick={copyToClipboard}>
<Button className={styles['copy-button']} title={t('CTX_COPY_TO_CLIPBOARD')} onClick={copyToClipboard}>
<Icon className={styles['icon']} name={'link'} />
<div className={styles['label']}>{ t('COPY') }</div>
</Button>

Some files were not shown because too many files have changed in this diff Show more