mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-03-11 17:15:48 +00:00
Merge branch 'development' into feat/gamepad-support
This commit is contained in:
commit
45ffc79b1b
53 changed files with 1023 additions and 371 deletions
65
.github/workflows/auto_assign.yml
vendored
Normal file
65
.github/workflows/auto_assign.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
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_name == 'pull_request'
|
||||
uses: actions/github-script@v6
|
||||
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
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: actions/github-script@v6
|
||||
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
|
||||
});
|
||||
}
|
||||
|
|
@ -1,67 +1,26 @@
|
|||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "9EWRZ4QP3J.com.stremio.one",
|
||||
"paths": [
|
||||
"/",
|
||||
"/#/player/*",
|
||||
"/#/discover/*",
|
||||
"/#/detail/*",
|
||||
"/#/library/*",
|
||||
"/#/addons/*",
|
||||
"/#/settings",
|
||||
"/#/search/*"
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"/": "/",
|
||||
"#": "/player/*",
|
||||
"comment": "Matches deep link for player"
|
||||
},
|
||||
{
|
||||
"/": "/",
|
||||
"#": "/discover/*",
|
||||
"comment": "Matches deep link for discover"
|
||||
},
|
||||
{
|
||||
"/": "/",
|
||||
"#": "/detail/*",
|
||||
"comment": "Matches deep link for detail"
|
||||
},
|
||||
{
|
||||
"/": "/",
|
||||
"#": "/library/*",
|
||||
"comment": "Matches deep link for library"
|
||||
},
|
||||
{
|
||||
"/": "/",
|
||||
"#": "/addons/*",
|
||||
"comment": "Matches deep link for addons"
|
||||
},
|
||||
{
|
||||
"/": "/",
|
||||
"#": "/settings",
|
||||
"comment": "Matches deep link for settings"
|
||||
},
|
||||
{
|
||||
"/": "/",
|
||||
"#": "/search/*",
|
||||
"comment": "Matches deep link for search"
|
||||
}
|
||||
]
|
||||
}
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"activitycontinuation": {
|
||||
"apps": [
|
||||
"9EWRZ4QP3J.com.stremio.one"
|
||||
]
|
||||
},
|
||||
"webcredentials": {
|
||||
"apps": [
|
||||
"9EWRZ4QP3J.com.stremio.one"
|
||||
]
|
||||
}
|
||||
}
|
||||
13
package-lock.json
generated
13
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "stremio",
|
||||
"version": "5.0.0-beta.20",
|
||||
"version": "5.0.0-beta.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stremio",
|
||||
"version": "5.0.0-beta.20",
|
||||
"version": "5.0.0-beta.23",
|
||||
"license": "gpl-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.26.0",
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
"@stremio/stremio-colors": "5.2.0",
|
||||
"@stremio/stremio-core-web": "https://stremio.github.io/stremio-core/stremio-core-web/feat/settings-gamepad-support/dev/stremio-stremio-core-web-0.49.2.tgz",
|
||||
"@stremio/stremio-icons": "5.4.1",
|
||||
"@stremio/stremio-video": "0.0.53",
|
||||
"@stremio/stremio-video": "0.0.60",
|
||||
"a-color-picker": "1.2.1",
|
||||
"bowser": "2.11.0",
|
||||
"buffer": "6.0.3",
|
||||
|
|
@ -3409,9 +3409,10 @@
|
|||
]
|
||||
},
|
||||
"node_modules/@stremio/stremio-video": {
|
||||
"version": "0.0.53",
|
||||
"resolved": "https://registry.npmjs.org/@stremio/stremio-video/-/stremio-video-0.0.53.tgz",
|
||||
"integrity": "sha512-hSlk8GqMdk4N8VbcdvduYqWVZsQLgHyU7GfFmd1k+t0pSpDKAhI3C6dohG5Sr09CKCjHa8D1rls+CwMNPXLSGw==",
|
||||
"version": "0.0.60",
|
||||
"resolved": "https://registry.npmjs.org/@stremio/stremio-video/-/stremio-video-0.0.60.tgz",
|
||||
"integrity": "sha512-RbmSi+Lk+3pb6f2ZkGVCnoMoJoujvVvSLDHiLGkXnzQwjYf2B2022NKlAQmHRuHN1sjD+VEsKD8foQH4hXGG1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer": "6.0.3",
|
||||
"color": "4.2.3",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "stremio",
|
||||
"displayName": "Stremio",
|
||||
"version": "5.0.0-beta.20",
|
||||
"version": "5.0.0-beta.23",
|
||||
"author": "Smart Code OOD",
|
||||
"private": true,
|
||||
"license": "gpl-2.0",
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
"@stremio/stremio-colors": "5.2.0",
|
||||
"@stremio/stremio-core-web": "https://stremio.github.io/stremio-core/stremio-core-web/feat/settings-gamepad-support/dev/stremio-stremio-core-web-0.49.2.tgz",
|
||||
"@stremio/stremio-icons": "5.4.1",
|
||||
"@stremio/stremio-video": "0.0.53",
|
||||
"@stremio/stremio-video": "0.0.60",
|
||||
"a-color-picker": "1.2.1",
|
||||
"bowser": "2.11.0",
|
||||
"buffer": "6.0.3",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ const RouterWithProtectedRoutes = withCoreSuspender(withProtectedRoutes(Router))
|
|||
const App = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const shell = useShell();
|
||||
const [windowHidden, setWindowHidden] = React.useState(false);
|
||||
const [gamepadSupportEnabled, setGamepadSupportEnabled] = React.useState(false);
|
||||
const onPathNotMatch = React.useCallback(() => {
|
||||
return NotFound;
|
||||
|
|
@ -103,25 +102,25 @@ const App = () => {
|
|||
|
||||
// Handle shell events
|
||||
React.useEffect(() => {
|
||||
const onWindowVisibilityChanged = (state) => {
|
||||
setWindowHidden(state.visible === false && state.visibility === 0);
|
||||
};
|
||||
|
||||
const onOpenMedia = (data) => {
|
||||
if (data.startsWith('stremio:///')) return;
|
||||
if (data.startsWith('stremio://')) {
|
||||
const transportUrl = data.replace('stremio://', 'https://');
|
||||
if (URL.canParse(transportUrl)) {
|
||||
window.location.href = `#/addons?addon=${encodeURIComponent(transportUrl)}`;
|
||||
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('win-visibility-changed', onWindowVisibilityChanged);
|
||||
shell.on('open-media', onOpenMedia);
|
||||
|
||||
return () => {
|
||||
shell.off('win-visibility-changed', onWindowVisibilityChanged);
|
||||
shell.off('open-media', onOpenMedia);
|
||||
};
|
||||
}, []);
|
||||
|
|
@ -138,7 +137,7 @@ const App = () => {
|
|||
setGamepadSupportEnabled(args.settings.gamepadSupport);
|
||||
}
|
||||
|
||||
if (args?.settings?.quitOnClose && windowHidden) {
|
||||
if (args?.settings?.quitOnClose && shell.windowClosed) {
|
||||
shell.send('quit');
|
||||
}
|
||||
|
||||
|
|
@ -155,7 +154,7 @@ const App = () => {
|
|||
setGamepadSupportEnabled(state.profile.settings.gamepadSupport);
|
||||
}
|
||||
|
||||
if (state?.profile?.settings?.quitOnClose && windowHidden) {
|
||||
if (state?.profile?.settings?.quitOnClose && shell.windowClosed) {
|
||||
shell.send('quit');
|
||||
}
|
||||
};
|
||||
|
|
@ -200,7 +199,7 @@ const App = () => {
|
|||
services.core.transport.off('CoreEvent', onCoreEvent);
|
||||
}
|
||||
};
|
||||
}, [initialized, windowHidden]);
|
||||
}, [initialized, shell.windowClosed]);
|
||||
return (
|
||||
<React.StrictMode>
|
||||
<ServicesProvider services={services}>
|
||||
|
|
|
|||
|
|
@ -151,14 +151,13 @@ svg {
|
|||
html {
|
||||
width: @html-width;
|
||||
height: @html-height;
|
||||
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;
|
||||
|
|
@ -168,6 +167,7 @@ html {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -106,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,
|
||||
|
|
@ -127,4 +129,5 @@ module.exports = {
|
|||
SUPPORTED_LOCAL_SUBTITLES,
|
||||
EXTERNAL_PLAYERS,
|
||||
WHITELISTED_HOSTS,
|
||||
PROTOCOL,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React, { createContext, useContext } from 'react';
|
||||
import { WHITELISTED_HOSTS } from 'stremio/common/CONSTANTS';
|
||||
import useShell from 'stremio/common/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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const routesRegexp = {
|
|||
urlParamsNames: []
|
||||
},
|
||||
board: {
|
||||
regexp: /^\/?$/,
|
||||
regexp: /^\/?(?:board)?$/,
|
||||
urlParamsNames: []
|
||||
},
|
||||
discover: {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import useShell, { type WindowVisibilityState } from './useShell';
|
||||
import useShell, { type WindowVisibility } from './useShell';
|
||||
import useSettings from './useSettings';
|
||||
|
||||
const useFullscreen = () => {
|
||||
|
|
@ -22,7 +22,9 @@ const useFullscreen = () => {
|
|||
if (shell.active) {
|
||||
shell.send('win-set-visibility', { fullscreen: false });
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
if (document.fullscreenElement === document.documentElement) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -31,7 +33,7 @@ const useFullscreen = () => {
|
|||
}, [fullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
const onWindowVisibilityChanged = (state: WindowVisibilityState) => {
|
||||
const onWindowVisibilityChanged = (state: WindowVisibility) => {
|
||||
setFullscreen(state.isFullscreen === true);
|
||||
};
|
||||
|
||||
|
|
@ -44,6 +46,10 @@ const useFullscreen = () => {
|
|||
exitFullscreen();
|
||||
}
|
||||
|
||||
if (event.code === 'KeyF') {
|
||||
toggleFullscreen();
|
||||
}
|
||||
|
||||
if (event.code === 'F11' && shell.active) {
|
||||
toggleFullscreen();
|
||||
}
|
||||
|
|
@ -58,7 +64,7 @@ const useFullscreen = () => {
|
|||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
||||
};
|
||||
}, [settings.escExitFullscreen]);
|
||||
}, [settings.escExitFullscreen, toggleFullscreen]);
|
||||
|
||||
return [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import EventEmitter from 'eventemitter3';
|
||||
|
||||
const SHELL_EVENT_OBJECT = 'transport';
|
||||
|
|
@ -17,13 +17,22 @@ type ShellEvent = {
|
|||
args: string[];
|
||||
};
|
||||
|
||||
export type WindowVisibilityState = {
|
||||
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);
|
||||
};
|
||||
|
|
@ -46,6 +55,24 @@ const useShell = () => {
|
|||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
|
|
@ -70,6 +97,8 @@ const useShell = () => {
|
|||
send,
|
||||
on,
|
||||
off,
|
||||
windowClosed,
|
||||
windowHidden,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,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 }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [shareModalOpen, openShareModal, closeShareModal] = useBinaryState(false);
|
||||
const linksGroups = React.useMemo(() => {
|
||||
|
|
@ -98,7 +98,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']}>
|
||||
|
|
@ -261,7 +261,7 @@ const MetaPreview = ({ className, compact, name, logo, background, runtime, rele
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
MetaPreview.Placeholder = MetaPreviewPlaceholder;
|
||||
|
||||
|
|
|
|||
|
|
@ -10,16 +10,16 @@ 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 [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 +94,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 +109,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']} />
|
||||
|
|
@ -126,7 +126,7 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, ren
|
|||
</div>
|
||||
}
|
||||
</div>
|
||||
), [options, selected, menuOnKeyDown, menuOnClick, optionOnClick]);
|
||||
), [filteredOptions, selected, menuOnKeyDown, menuOnClick, optionOnClick]);
|
||||
const renderPopupLabel = React.useMemo(() => (labelProps) => {
|
||||
return renderLabel({
|
||||
...labelProps,
|
||||
|
|
|
|||
|
|
@ -10,23 +10,25 @@ import styles from './Dropdown.less';
|
|||
|
||||
type Props = {
|
||||
options: MultiselectMenuOption[];
|
||||
selectedOption?: MultiselectMenuOption | null;
|
||||
value?: string | number;
|
||||
menuOpen: boolean | (() => void);
|
||||
level: number;
|
||||
setLevel: (level: number) => void;
|
||||
onSelect: (value: number) => void;
|
||||
onSelect: (value: string | number) => 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: string | number) => (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}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,13 +8,12 @@ import Icon from '@stremio/stremio-icons/react';
|
|||
|
||||
type Props = {
|
||||
option: MultiselectMenuOption;
|
||||
selectedOption?: MultiselectMenuOption | null;
|
||||
onSelect: (value: number) => void;
|
||||
selectedValue?: string | number;
|
||||
onSelect: (value: string | number) => 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);
|
||||
|
|
|
|||
|
|
@ -14,14 +14,21 @@
|
|||
}
|
||||
|
||||
.multiselect-button {
|
||||
color: var(--primary-foreground-color);
|
||||
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 +40,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
&:hover, &.active {
|
||||
background-color: var(--overlay-color);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,23 +11,25 @@ import useOutsideClick from 'stremio/common/useOutsideClick';
|
|||
|
||||
type Props = {
|
||||
className?: string,
|
||||
title?: string;
|
||||
title?: string | (() => string);
|
||||
options: MultiselectMenuOption[];
|
||||
selectedOption?: MultiselectMenuOption;
|
||||
onSelect: (value: number) => void;
|
||||
value?: string | number;
|
||||
onSelect: (value: string | number) => void;
|
||||
};
|
||||
|
||||
const MultiselectMenu = ({ className, title, options, selectedOption, onSelect }: Props) => {
|
||||
const MultiselectMenu = ({ className, title, options, value, 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 })}
|
||||
onClick={toggleMenu}
|
||||
|
|
@ -35,7 +37,13 @@ const MultiselectMenu = ({ className, title, options, selectedOption, onSelect }
|
|||
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 +54,7 @@ const MultiselectMenu = ({ className, title, options, selectedOption, onSelect }
|
|||
options={options}
|
||||
onSelect={onOptionSelect}
|
||||
menuOpen={menuOpen}
|
||||
selectedOption={selectedOption}
|
||||
value={value}
|
||||
/>
|
||||
: null
|
||||
}
|
||||
|
|
|
|||
65
src/components/NumberInput/NumberInput.less
Normal file
65
src/components/NumberInput/NumberInput.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
src/components/NumberInput/NumberInput.tsx
Normal file
113
src/components/NumberInput/NumberInput.tsx
Normal 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);
|
||||
5
src/components/NumberInput/index.ts
Normal file
5
src/components/NumberInput/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import NumberInput from './NumberInput';
|
||||
|
||||
export default NumberInput;
|
||||
|
|
@ -19,6 +19,7 @@ import ModalDialog from './ModalDialog';
|
|||
import Multiselect from './Multiselect';
|
||||
import MultiselectMenu from './MultiselectMenu';
|
||||
import { HorizontalNavBar, VerticalNavBar } from './NavBar';
|
||||
import NumberInput from './NumberInput';
|
||||
import Popup from './Popup';
|
||||
import RadioButton from './RadioButton';
|
||||
import SearchBar from './SearchBar';
|
||||
|
|
@ -52,6 +53,7 @@ export {
|
|||
MultiselectMenu,
|
||||
HorizontalNavBar,
|
||||
VerticalNavBar,
|
||||
NumberInput,
|
||||
Popup,
|
||||
RadioButton,
|
||||
SearchBar,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
<div id="app"></div>
|
||||
<%= htmlWebpackPlugin.tags.bodyTags %>
|
||||
<script src="//www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"></script>
|
||||
<script async src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
|
@ -6,7 +6,7 @@ const classnames = require('classnames');
|
|||
const { useTranslation } = require('react-i18next');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { usePlatform, useBinaryState, withCoreSuspender } = require('stremio/common');
|
||||
const { AddonDetailsModal, Button, Image, MainNavBars, Multiselect, ModalDialog, SearchBar, SharePrompt, TextInput } = require('stremio/components');
|
||||
const { AddonDetailsModal, Button, Image, MainNavBars, ModalDialog, SearchBar, SharePrompt, TextInput, MultiselectMenu } = require('stremio/components');
|
||||
const { useServices } = require('stremio/services');
|
||||
const Addon = require('./Addon');
|
||||
const useInstalledAddons = require('./useInstalledAddons');
|
||||
|
|
@ -107,7 +107,7 @@ const Addons = ({ urlParams, queryParams }) => {
|
|||
<div className={styles['addons-content']}>
|
||||
<div className={styles['selectable-inputs-container']}>
|
||||
{selectInputs.map((selectInput, index) => (
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
{...selectInput}
|
||||
key={index}
|
||||
className={styles['select-input-container']}
|
||||
|
|
@ -218,7 +218,7 @@ const Addons = ({ urlParams, queryParams }) => {
|
|||
filtersModalOpen ?
|
||||
<ModalDialog title={'Addons filters'} className={styles['filters-modal']} onCloseRequest={closeFiltersModal}>
|
||||
{selectInputs.map((selectInput, index) => (
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
{...selectInput}
|
||||
key={index}
|
||||
className={styles['select-input-container']}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@
|
|||
}
|
||||
|
||||
.select-input-container {
|
||||
background-color: var(--overlay-color);
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: 15rem;
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ const React = require('react');
|
|||
const { useTranslate } = require('stremio/common');
|
||||
|
||||
const mapSelectableInputs = (installedAddons, remoteAddons, t) => {
|
||||
const selectedCatalog = remoteAddons.selectable.catalogs.concat(installedAddons.selectable.catalogs).find(({ selected }) => selected);
|
||||
const catalogSelect = {
|
||||
title: t.string('SELECT_CATALOG'),
|
||||
options: remoteAddons.selectable.catalogs
|
||||
.concat(installedAddons.selectable.catalogs)
|
||||
.map(({ name, deepLinks }) => ({
|
||||
|
|
@ -13,24 +13,22 @@ const mapSelectableInputs = (installedAddons, remoteAddons, t) => {
|
|||
label: t.stringWithPrefix(name, 'ADDON_'),
|
||||
title: t.stringWithPrefix(name, 'ADDON_'),
|
||||
})),
|
||||
selected: remoteAddons.selectable.catalogs
|
||||
.concat(installedAddons.selectable.catalogs)
|
||||
.filter(({ selected }) => selected)
|
||||
.map(({ deepLinks }) => deepLinks.addons),
|
||||
renderLabelText: remoteAddons.selected !== null ?
|
||||
value: selectedCatalog ? selectedCatalog.deepLinks.addons : undefined,
|
||||
title: remoteAddons.selected !== null ?
|
||||
() => {
|
||||
const selectableCatalog = remoteAddons.selectable.catalogs
|
||||
.find(({ id }) => id === remoteAddons.selected.request.path.id);
|
||||
return selectableCatalog ? t.stringWithPrefix(selectableCatalog.name, 'ADDON_') : remoteAddons.selected.request.path.id;
|
||||
}
|
||||
:
|
||||
null,
|
||||
onSelect: (event) => {
|
||||
window.location = event.value;
|
||||
: null,
|
||||
onSelect: (value) => {
|
||||
window.location = value;
|
||||
}
|
||||
};
|
||||
const selectedType = installedAddons.selected !== null
|
||||
? installedAddons.selectable.types.find(({ selected }) => selected)
|
||||
: remoteAddons.selectable.types.find(({ selected }) => selected);
|
||||
const typeSelect = {
|
||||
title: t.string('SELECT_TYPE'),
|
||||
options: installedAddons.selected !== null ?
|
||||
installedAddons.selectable.types.map(({ type, deepLinks }) => ({
|
||||
value: deepLinks.addons,
|
||||
|
|
@ -41,15 +39,8 @@ const mapSelectableInputs = (installedAddons, remoteAddons, t) => {
|
|||
value: deepLinks.addons,
|
||||
label: t.stringWithPrefix(type, 'TYPE_')
|
||||
})),
|
||||
selected: installedAddons.selected !== null ?
|
||||
installedAddons.selectable.types
|
||||
.filter(({ selected }) => selected)
|
||||
.map(({ deepLinks }) => deepLinks.addons)
|
||||
:
|
||||
remoteAddons.selectable.types
|
||||
.filter(({ selected }) => selected)
|
||||
.map(({ deepLinks }) => deepLinks.addons),
|
||||
renderLabelText: () => {
|
||||
value: selectedType ? selectedType.deepLinks.addons : undefined,
|
||||
title: () => {
|
||||
return installedAddons.selected !== null ?
|
||||
installedAddons.selected.request.type === null ?
|
||||
t.string('TYPE_ALL')
|
||||
|
|
@ -61,8 +52,8 @@ const mapSelectableInputs = (installedAddons, remoteAddons, t) => {
|
|||
:
|
||||
typeSelect.title;
|
||||
},
|
||||
onSelect: (event) => {
|
||||
window.location = event.value;
|
||||
onSelect: (value) => {
|
||||
window.location = value;
|
||||
}
|
||||
};
|
||||
return [catalogSelect, typeSelect];
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ const classnames = require('classnames');
|
|||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { CONSTANTS, useBinaryState, useOnScrollToBottom, withCoreSuspender } = require('stremio/common');
|
||||
const { AddonDetailsModal, Button, DelayedRenderer, Image, MainNavBars, MetaItem, MetaPreview, Multiselect, ModalDialog } = require('stremio/components');
|
||||
const { AddonDetailsModal, Button, DelayedRenderer, Image, MainNavBars, MetaItem, MetaPreview, ModalDialog, MultiselectMenu } = require('stremio/components');
|
||||
const useDiscover = require('./useDiscover');
|
||||
const useSelectableInputs = require('./useSelectableInputs');
|
||||
const styles = require('./styles');
|
||||
|
||||
const SCROLL_TO_BOTTOM_TRESHOLD = 400;
|
||||
const SCROLL_TO_BOTTOM_THRESHOLD = 400;
|
||||
|
||||
const Discover = ({ urlParams, queryParams }) => {
|
||||
const { core } = useServices();
|
||||
|
|
@ -20,12 +20,24 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
const [inputsModalOpen, openInputsModal, closeInputsModal] = useBinaryState(false);
|
||||
const [addonModalOpen, openAddonModal, closeAddonModal] = useBinaryState(false);
|
||||
const [selectedMetaItemIndex, setSelectedMetaItemIndex] = React.useState(0);
|
||||
|
||||
const metasContainerRef = React.useRef();
|
||||
const metaPreviewRef = React.useRef();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (discover.catalog?.content.type === 'Loading') {
|
||||
metasContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [discover.catalog]);
|
||||
React.useEffect(() => {
|
||||
if (hasNextPage && metasContainerRef.current) {
|
||||
const containerHeight = metasContainerRef.current.scrollHeight;
|
||||
const viewportHeight = metasContainerRef.current.clientHeight;
|
||||
if (containerHeight <= viewportHeight + SCROLL_TO_BOTTOM_THRESHOLD) {
|
||||
loadNextPage();
|
||||
}
|
||||
}
|
||||
}, [hasNextPage, loadNextPage]);
|
||||
const selectedMetaItem = React.useMemo(() => {
|
||||
return discover.catalog !== null &&
|
||||
discover.catalog.content.type === 'Ready' &&
|
||||
|
|
@ -66,7 +78,8 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
}
|
||||
}, []);
|
||||
const metaItemOnClick = React.useCallback((event) => {
|
||||
if (event.currentTarget.dataset.index !== selectedMetaItemIndex.toString()) {
|
||||
const visible = window.getComputedStyle(metaPreviewRef.current).display !== 'none';
|
||||
if (event.currentTarget.dataset.index !== selectedMetaItemIndex.toString() && visible) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus();
|
||||
}
|
||||
|
|
@ -76,7 +89,7 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
loadNextPage();
|
||||
}
|
||||
}, [hasNextPage, loadNextPage]);
|
||||
const onScroll = useOnScrollToBottom(onScrollToBottom, SCROLL_TO_BOTTOM_TRESHOLD);
|
||||
const onScroll = useOnScrollToBottom(onScrollToBottom, SCROLL_TO_BOTTOM_THRESHOLD);
|
||||
React.useEffect(() => {
|
||||
closeInputsModal();
|
||||
closeAddonModal();
|
||||
|
|
@ -87,20 +100,21 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
<div className={styles['discover-content']}>
|
||||
<div className={styles['catalog-container']}>
|
||||
<div className={styles['selectable-inputs-container']}>
|
||||
{selectInputs.map(({ title, options, selected, renderLabelText, onSelect }, index) => (
|
||||
<Multiselect
|
||||
{selectInputs.map(({ title, options, value, onSelect }, index) => (
|
||||
<MultiselectMenu
|
||||
key={index}
|
||||
className={styles['select-input']}
|
||||
title={title}
|
||||
options={options}
|
||||
selected={selected}
|
||||
renderLabelText={renderLabelText}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
<Button className={styles['filter-container']} title={'All filters'} onClick={openInputsModal}>
|
||||
<Icon className={styles['filter-icon']} name={'filters'} />
|
||||
</Button>
|
||||
<div className={styles['filter-container']}>
|
||||
<Button className={styles['filter-button']} title={'All filters'} onClick={openInputsModal}>
|
||||
<Icon className={styles['filter-icon']} name={'filters'} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
discover.catalog !== null && !discover.catalog.installed ?
|
||||
|
|
@ -164,6 +178,7 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
<MetaPreview
|
||||
className={styles['meta-preview-container']}
|
||||
compact={true}
|
||||
ref={metaPreviewRef}
|
||||
name={selectedMetaItem.name}
|
||||
logo={selectedMetaItem.logo}
|
||||
background={selectedMetaItem.poster}
|
||||
|
|
@ -187,14 +202,13 @@ const Discover = ({ urlParams, queryParams }) => {
|
|||
{
|
||||
inputsModalOpen ?
|
||||
<ModalDialog title={'Catalog filters'} className={styles['selectable-inputs-modal']} onCloseRequest={closeInputsModal}>
|
||||
{selectInputs.map(({ title, options, selected, renderLabelText, onSelect }, index) => (
|
||||
<Multiselect
|
||||
{selectInputs.map(({ title, options, value, onSelect }, index) => (
|
||||
<MultiselectMenu
|
||||
key={index}
|
||||
className={styles['select-input']}
|
||||
title={title}
|
||||
options={options}
|
||||
selected={selected}
|
||||
renderLabelText={renderLabelText}
|
||||
value={value}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@
|
|||
|
||||
.select-input {
|
||||
flex: 0 1 15rem;
|
||||
background-color: var(--overlay-color);
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: 1.5rem;
|
||||
|
|
@ -59,7 +60,9 @@
|
|||
display: none;
|
||||
|
||||
&~.filter-container {
|
||||
display: flex;
|
||||
.filter-button {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -69,20 +72,27 @@
|
|||
}
|
||||
|
||||
.filter-container {
|
||||
flex: none;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--overlay-color);
|
||||
display: flex;
|
||||
flex: 1 0 5rem;
|
||||
justify-content: flex-end;
|
||||
|
||||
.filter-icon {
|
||||
.filter-button {
|
||||
flex: none;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
color: var(--primary-foreground-color);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin-left: 1.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--overlay-color);
|
||||
|
||||
.filter-icon {
|
||||
flex: none;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -219,9 +229,14 @@
|
|||
|
||||
.select-input {
|
||||
height: 3.5rem;
|
||||
display: none;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 1rem;
|
||||
&:nth-child(n+4) {
|
||||
display: flex;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.multiselect-menu-container {
|
||||
|
|
@ -363,7 +378,9 @@
|
|||
display: none;
|
||||
|
||||
&~.filter-container {
|
||||
display: flex;
|
||||
.filter-button {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -375,4 +392,22 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selectable-inputs-modal {
|
||||
.selectable-inputs-modal-container {
|
||||
.selectable-inputs-modal-content {
|
||||
.select-input {
|
||||
display: none;
|
||||
|
||||
&:nth-child(n+2) {
|
||||
display: flex;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,72 +4,70 @@ const React = require('react');
|
|||
const { useTranslate } = require('stremio/common');
|
||||
|
||||
const mapSelectableInputs = (discover, t) => {
|
||||
const selectedType = discover.selectable.types.find(({ selected }) => selected);
|
||||
const typeSelect = {
|
||||
title: t.string('SELECT_TYPE'),
|
||||
options: discover.selectable.types
|
||||
.map(({ type, deepLinks }) => ({
|
||||
value: deepLinks.discover,
|
||||
label: t.stringWithPrefix(type, 'TYPE_')
|
||||
})),
|
||||
selected: discover.selectable.types
|
||||
.filter(({ selected }) => selected)
|
||||
.map(({ deepLinks }) => deepLinks.discover),
|
||||
renderLabelText: discover.selected !== null ?
|
||||
() => t.stringWithPrefix(discover.selected.request.path.type, 'TYPE_')
|
||||
:
|
||||
null,
|
||||
onSelect: (event) => {
|
||||
window.location = event.value;
|
||||
value: selectedType
|
||||
? selectedType.deepLinks.discover
|
||||
: undefined,
|
||||
title: discover.selected !== null
|
||||
? () => t.stringWithPrefix(discover.selected.request.path.type, 'TYPE_')
|
||||
: t.string('SELECT_TYPE'),
|
||||
onSelect: (value) => {
|
||||
window.location = value;
|
||||
}
|
||||
};
|
||||
const selectedCatalog = discover.selectable.catalogs.find(({ selected }) => selected);
|
||||
const catalogSelect = {
|
||||
title: t.string('SELECT_CATALOG'),
|
||||
options: discover.selectable.catalogs
|
||||
.map(({ id, name, addon, deepLinks }) => ({
|
||||
value: deepLinks.discover,
|
||||
label: t.catalogTitle({ addon, id, name }),
|
||||
title: `${name} (${addon.manifest.name})`
|
||||
})),
|
||||
selected: discover.selectable.catalogs
|
||||
.filter(({ selected }) => selected)
|
||||
.map(({ deepLinks }) => deepLinks.discover),
|
||||
renderLabelText: discover.selected !== null ?
|
||||
() => {
|
||||
value: discover.selected?.request.path.id
|
||||
? selectedCatalog.deepLinks.discover
|
||||
: undefined,
|
||||
title: discover.selected !== null
|
||||
? () => {
|
||||
const selectableCatalog = discover.selectable.catalogs
|
||||
.find(({ id }) => id === discover.selected.request.path.id);
|
||||
return selectableCatalog ? t.catalogTitle(selectableCatalog, false) : discover.selected.request.path.id;
|
||||
}
|
||||
:
|
||||
null,
|
||||
onSelect: (event) => {
|
||||
window.location = event.value;
|
||||
t.string('SELECT_CATALOG'),
|
||||
onSelect: (value) => {
|
||||
window.location =value;
|
||||
}
|
||||
};
|
||||
const extraSelects = discover.selectable.extra.map(({ name, isRequired, options }) => ({
|
||||
title: t.stringWithPrefix(name, 'SELECT_'),
|
||||
isRequired: isRequired,
|
||||
options: options.map(({ value, deepLinks }) => ({
|
||||
label: typeof value === 'string' ? t.stringWithPrefix(value) : t.string('NONE'),
|
||||
value: JSON.stringify({
|
||||
href: deepLinks.discover,
|
||||
value
|
||||
})
|
||||
})),
|
||||
selected: options
|
||||
.filter(({ selected }) => selected)
|
||||
.map(({ value, deepLinks }) => JSON.stringify({
|
||||
href: deepLinks.discover,
|
||||
value
|
||||
const extraSelects = discover.selectable.extra.map(({ name, isRequired, options }) => {
|
||||
const selectedExtra = options.find(({ selected }) => selected);
|
||||
return {
|
||||
isRequired: isRequired,
|
||||
options: options.map(({ value, deepLinks }) => ({
|
||||
label: typeof value === 'string' ? t.stringWithPrefix(value) : t.string('NONE'),
|
||||
value: JSON.stringify({
|
||||
href: deepLinks.discover,
|
||||
value
|
||||
})
|
||||
})),
|
||||
renderLabelText: options.some(({ selected, value }) => selected && value === null) ?
|
||||
() => t.stringWithPrefix(name, 'SELECT_')
|
||||
:
|
||||
null,
|
||||
onSelect: (event) => {
|
||||
const { href } = JSON.parse(event.value);
|
||||
window.location = href;
|
||||
}
|
||||
}));
|
||||
value: JSON.stringify({
|
||||
href: selectedExtra.deepLinks.discover,
|
||||
value: selectedExtra.value,
|
||||
}),
|
||||
title: options.some(({ selected, value }) => selected && value === null) ?
|
||||
() => t.stringWithPrefix(name, 'SELECT_')
|
||||
: t.stringWithPrefix(selectedExtra.value),
|
||||
onSelect: (value) => {
|
||||
const { href } = JSON.parse(value);
|
||||
window.location = href;
|
||||
}
|
||||
};
|
||||
});
|
||||
return [[typeSelect, catalogSelect, ...extraSelects], discover.selectable.nextPage];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ const { Button, Image, Checkbox } = require('stremio/components');
|
|||
const CredentialsTextInput = require('./CredentialsTextInput');
|
||||
const PasswordResetModal = require('./PasswordResetModal');
|
||||
const useFacebookLogin = require('./useFacebookLogin');
|
||||
const { default: useAppleLogin } = require('./useAppleLogin');
|
||||
|
||||
const styles = require('./styles');
|
||||
|
||||
const SIGNUP_FORM = 'signup';
|
||||
|
|
@ -22,6 +24,7 @@ const Intro = ({ queryParams }) => {
|
|||
const { t } = useTranslation();
|
||||
const routeFocused = useRouteFocused();
|
||||
const [startFacebookLogin, stopFacebookLogin] = useFacebookLogin();
|
||||
const [startAppleLogin, stopAppleLogin] = useAppleLogin();
|
||||
const emailRef = React.useRef(null);
|
||||
const passwordRef = React.useRef(null);
|
||||
const confirmPasswordRef = React.useRef(null);
|
||||
|
|
@ -106,6 +109,33 @@ const Intro = ({ queryParams }) => {
|
|||
stopFacebookLogin();
|
||||
closeLoaderModal();
|
||||
}, []);
|
||||
const loginWithApple = React.useCallback(() => {
|
||||
openLoaderModal();
|
||||
startAppleLogin()
|
||||
.then(({ token, sub, email, name }) => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'Authenticate',
|
||||
args: {
|
||||
type: 'Apple',
|
||||
token,
|
||||
sub,
|
||||
email,
|
||||
name
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
closeLoaderModal();
|
||||
dispatch({ type: 'error', error: error.message });
|
||||
});
|
||||
}, []);
|
||||
const cancelLoginWithApple = React.useCallback(() => {
|
||||
stopAppleLogin();
|
||||
closeLoaderModal();
|
||||
}, []);
|
||||
const loginWithEmail = React.useCallback(() => {
|
||||
if (typeof state.email !== 'string' || state.email.length === 0 || !emailRef.current.validity.valid) {
|
||||
dispatch({ type: 'error', error: 'Invalid email' });
|
||||
|
|
@ -336,7 +366,7 @@ const Intro = ({ queryParams }) => {
|
|||
</div>
|
||||
}
|
||||
{
|
||||
state.error.length > 0 ?
|
||||
state.error && state.error.length > 0 ?
|
||||
<div ref={errorRef} className={styles['error-message']}>{state.error}</div>
|
||||
:
|
||||
null
|
||||
|
|
@ -350,6 +380,10 @@ const Intro = ({ queryParams }) => {
|
|||
<Icon className={styles['icon']} name={'facebook'} />
|
||||
<div className={styles['label']}>Continue with Facebook</div>
|
||||
</Button>
|
||||
<Button className={classnames(styles['form-button'], styles['apple-button'])} onClick={loginWithApple}>
|
||||
<Icon className={styles['icon']} name={'macos'} />
|
||||
<div className={styles['label']}>Continue with Apple</div>
|
||||
</Button>
|
||||
{
|
||||
state.form === SIGNUP_FORM ?
|
||||
<Button className={classnames(styles['form-button'], styles['login-form-button'])} onClick={switchFormOnClick}>
|
||||
|
|
@ -388,7 +422,7 @@ const Intro = ({ queryParams }) => {
|
|||
<div className={styles['loader-container']}>
|
||||
<Icon className={styles['icon']} name={'person'} />
|
||||
<div className={styles['label']}>Authenticating...</div>
|
||||
<Button className={styles['button']} onClick={cancelLoginWithFacebook}>
|
||||
<Button className={styles['button']} onClick={cancelLoginWithFacebook && cancelLoginWithApple}>
|
||||
{t('BUTTON_CANCEL')}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -175,15 +175,43 @@
|
|||
position: relative;
|
||||
width: 22rem;
|
||||
margin-left: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.facebook-button {
|
||||
background: var(--color-facebook);
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:hover, &:focus {
|
||||
outline: var(--focus-outline-size) solid var(--color-facebook);
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.apple-button {
|
||||
background: var(--primary-foreground-color);
|
||||
|
||||
.icon {
|
||||
color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
&:hover, &:focus {
|
||||
outline: var(--focus-outline-size) solid var(--primary-foreground-color);
|
||||
background-color: transparent;
|
||||
|
||||
.icon {
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
81
src/routes/Intro/useAppleLogin.ts
Normal file
81
src/routes/Intro/useAppleLogin.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { usePlatform } from 'stremio/common';
|
||||
import hat from 'hat';
|
||||
|
||||
type AppleLoginResponse = {
|
||||
token: string;
|
||||
sub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const STREMIO_URL = 'https://www.strem.io';
|
||||
const MAX_TRIES = 25;
|
||||
|
||||
const getCredentials = async (state: string): Promise<AppleLoginResponse> => {
|
||||
try {
|
||||
const response = await fetch(`${STREMIO_URL}/login-apple-get-acc/${state}`);
|
||||
const { user } = await response.json();
|
||||
|
||||
return Promise.resolve({
|
||||
token: user.token,
|
||||
sub: user.sub,
|
||||
email: user.email,
|
||||
// We might not receive a name from Apple, so we use an empty string as a fallback
|
||||
name: user.name ?? '',
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to get credentials from Apple auth', e);
|
||||
return Promise.reject(e);
|
||||
}
|
||||
};
|
||||
|
||||
const useAppleLogin = (): [() => Promise<AppleLoginResponse>, () => void] => {
|
||||
const platform = usePlatform();
|
||||
const started = useRef(false);
|
||||
const timeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const start = useCallback(() => new Promise<AppleLoginResponse>((resolve, reject) => {
|
||||
started.current = true;
|
||||
const state = hat(128);
|
||||
let tries = 0;
|
||||
|
||||
platform.openExternal(`${STREMIO_URL}/login-apple/${state}`);
|
||||
|
||||
const waitForCredentials = () => {
|
||||
if (started.current) {
|
||||
timeout.current && clearTimeout(timeout.current);
|
||||
timeout.current = setTimeout(() => {
|
||||
if (tries >= MAX_TRIES)
|
||||
return reject(new Error('Failed to authenticate with Apple'));
|
||||
|
||||
tries++;
|
||||
|
||||
getCredentials(state)
|
||||
.then(resolve)
|
||||
.catch(waitForCredentials);
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
waitForCredentials();
|
||||
}), []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
started.current = false;
|
||||
timeout.current && clearTimeout(timeout.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => stop();
|
||||
}, []);
|
||||
|
||||
return [
|
||||
start,
|
||||
stop,
|
||||
];
|
||||
};
|
||||
|
||||
export default useAppleLogin;
|
||||
|
|
@ -5,7 +5,7 @@ const PropTypes = require('prop-types');
|
|||
const classnames = require('classnames');
|
||||
const NotFound = require('stremio/routes/NotFound');
|
||||
const { useProfile, useNotifications, routesRegexp, useOnScrollToBottom, withCoreSuspender } = require('stremio/common');
|
||||
const { DelayedRenderer, Chips, Image, MainNavBars, Multiselect, LibItem } = require('stremio/components');
|
||||
const { DelayedRenderer, Chips, Image, MainNavBars, LibItem, MultiselectMenu } = require('stremio/components');
|
||||
const { default: Placeholder } = require('./Placeholder');
|
||||
const useLibrary = require('./useLibrary');
|
||||
const useSelectableInputs = require('./useSelectableInputs');
|
||||
|
|
@ -63,13 +63,18 @@ const Library = ({ model, urlParams, queryParams }) => {
|
|||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [profile.auth, library.selected]);
|
||||
React.useEffect(() => {
|
||||
if (!library.selected?.type && typeSelect.value) {
|
||||
window.location = typeSelect.value;
|
||||
}
|
||||
}, [typeSelect.value, library.selected]);
|
||||
return (
|
||||
<MainNavBars className={styles['library-container']} route={model}>
|
||||
{
|
||||
profile.auth !== null ?
|
||||
<div className={styles['library-content']}>
|
||||
<div className={styles['selectable-inputs-container']}>
|
||||
<Multiselect {...typeSelect} className={styles['select-input-container']} />
|
||||
<MultiselectMenu {...typeSelect} className={styles['select-input-container']} />
|
||||
<Chips {...sortChips} className={styles['select-input-container']} />
|
||||
</div>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@
|
|||
flex-shrink: 1;
|
||||
flex-basis: 15rem;
|
||||
height: 2.75rem;
|
||||
background-color: var(--overlay-color);
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 1.5rem;
|
||||
|
|
|
|||
|
|
@ -2,20 +2,17 @@
|
|||
|
||||
const React = require('react');
|
||||
const { useTranslate } = require('stremio/common');
|
||||
|
||||
const mapSelectableInputs = (library, t) => {
|
||||
const selectedType = library.selectable.types.find(({ selected }) => selected) || library.selectable.types.find(({ type }) => type === null);
|
||||
const typeSelect = {
|
||||
title: t.string('SELECT_TYPE'),
|
||||
options: library.selectable.types
|
||||
.map(({ type, deepLinks }) => ({
|
||||
value: deepLinks.library,
|
||||
label: type === null ? t.string('TYPE_ALL') : t.stringWithPrefix(type, 'TYPE_')
|
||||
})),
|
||||
selected: library.selectable.types
|
||||
.filter(({ selected }) => selected)
|
||||
.map(({ deepLinks }) => deepLinks.library),
|
||||
onSelect: (event) => {
|
||||
window.location = event.value;
|
||||
value: selectedType?.deepLinks.library,
|
||||
onSelect: (value) => {
|
||||
window.location = value;
|
||||
}
|
||||
};
|
||||
const sortChips = {
|
||||
|
|
|
|||
29
src/routes/MetaDetails/EpisodePicker/EpisodePicker.less
Normal file
29
src/routes/MetaDetails/EpisodePicker/EpisodePicker.less
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
.button-container {
|
||||
flex: none;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: var(--focus-outline-size) solid var(--primary-accent-color);
|
||||
background-color: var(--primary-accent-color);
|
||||
height: 4rem;
|
||||
padding: 0 2rem;
|
||||
margin: 1rem auto;
|
||||
border-radius: 2rem;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 0 1 auto;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
max-height: 3.5rem;
|
||||
text-align: center;
|
||||
color: var(--primary-foreground-color);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
71
src/routes/MetaDetails/EpisodePicker/EpisodePicker.tsx
Normal file
71
src/routes/MetaDetails/EpisodePicker/EpisodePicker.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import React, { useCallback, useMemo, useState, ChangeEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, NumberInput } from 'stremio/components';
|
||||
import styles from './EpisodePicker.less';
|
||||
|
||||
type Props = {
|
||||
className?: string,
|
||||
seriesId: string;
|
||||
onSubmit: (season: number, episode: number) => void;
|
||||
};
|
||||
|
||||
const EpisodePicker = ({ className, onSubmit }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { initialSeason, initialEpisode } = useMemo(() => {
|
||||
const splitPath = window.location.hash.split('/');
|
||||
const videoId = decodeURIComponent(splitPath[splitPath.length - 1]);
|
||||
const [, pathSeason, pathEpisode] = videoId ? videoId.split(':') : [];
|
||||
return {
|
||||
initialSeason: parseInt(pathSeason) || 0,
|
||||
initialEpisode: parseInt(pathEpisode) || 1
|
||||
};
|
||||
}, []);
|
||||
|
||||
const [season, setSeason] = useState(initialSeason);
|
||||
const [episode, setEpisode] = useState(initialEpisode);
|
||||
|
||||
const handleSeasonChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setSeason(parseInt(event.target.value));
|
||||
}, []);
|
||||
|
||||
const handleEpisodeChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setEpisode(parseInt(event.target.value));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit(season, episode);
|
||||
};
|
||||
|
||||
const disabled = season === initialSeason && episode === initialEpisode;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<NumberInput
|
||||
min={0}
|
||||
label={t('SEASON')}
|
||||
defaultValue={season}
|
||||
onChange={handleSeasonChange}
|
||||
showButtons
|
||||
/>
|
||||
<NumberInput
|
||||
min={1}
|
||||
label={t('EPISODE')}
|
||||
defaultValue={episode}
|
||||
onChange={handleEpisodeChange}
|
||||
showButtons
|
||||
/>
|
||||
<Button
|
||||
className={styles['button-container']}
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className={styles['label']}>{t('SIDEBAR_SHOW_STREAMS')}</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EpisodePicker;
|
||||
5
src/routes/MetaDetails/EpisodePicker/index.ts
Normal file
5
src/routes/MetaDetails/EpisodePicker/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import SeasonEpisodePicker from './EpisodePicker';
|
||||
|
||||
export default SeasonEpisodePicker;
|
||||
|
|
@ -77,6 +77,13 @@ const MetaDetails = ({ urlParams, queryParams }) => {
|
|||
const seasonOnSelect = React.useCallback((event) => {
|
||||
setSeason(event.value);
|
||||
}, [setSeason]);
|
||||
const handleEpisodeSearch = React.useCallback((season, episode) => {
|
||||
const searchVideoHash = encodeURIComponent(`${urlParams.id}:${season}:${episode}`);
|
||||
const url = window.location.hash;
|
||||
const searchVideoPath = url.replace(encodeURIComponent(urlParams.videoId), searchVideoHash);
|
||||
window.location = searchVideoPath;
|
||||
}, [urlParams, window.location]);
|
||||
|
||||
const renderBackgroundImageFallback = React.useCallback(() => null, []);
|
||||
const renderBackground = React.useMemo(() => !!(
|
||||
metaPath &&
|
||||
|
|
@ -131,7 +138,7 @@ const MetaDetails = ({ urlParams, queryParams }) => {
|
|||
metaDetails.metaItem === null ?
|
||||
<div className={styles['meta-message-container']}>
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<div className={styles['message-label']}>No addons ware requested for this meta!</div>
|
||||
<div className={styles['message-label']}>No addons were requested for this meta!</div>
|
||||
</div>
|
||||
:
|
||||
metaDetails.metaItem.content.type === 'Err' ?
|
||||
|
|
@ -171,6 +178,8 @@ const MetaDetails = ({ urlParams, queryParams }) => {
|
|||
className={styles['streams-list']}
|
||||
streams={metaDetails.streams}
|
||||
video={video}
|
||||
type={streamPath.type}
|
||||
onEpisodeSearch={handleEpisodeSearch}
|
||||
/>
|
||||
:
|
||||
metaPath !== null ?
|
||||
|
|
|
|||
|
|
@ -5,28 +5,29 @@ const PropTypes = require('prop-types');
|
|||
const classnames = require('classnames');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { Button, Image, Multiselect } = require('stremio/components');
|
||||
const { Button, Image, MultiselectMenu } = require('stremio/components');
|
||||
const { useServices } = require('stremio/services');
|
||||
const Stream = require('./Stream');
|
||||
const styles = require('./styles');
|
||||
const { usePlatform, useProfile } = require('stremio/common');
|
||||
const { default: SeasonEpisodePicker } = require('../EpisodePicker');
|
||||
|
||||
const ALL_ADDONS_KEY = 'ALL';
|
||||
|
||||
const StreamsList = ({ className, video, ...props }) => {
|
||||
const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
const platform = usePlatform();
|
||||
const profile = useProfile();
|
||||
const streamsContainerRef = React.useRef(null);
|
||||
const [selectedAddon, setSelectedAddon] = React.useState(ALL_ADDONS_KEY);
|
||||
const onAddonSelected = React.useCallback((event) => {
|
||||
const onAddonSelected = React.useCallback((value) => {
|
||||
streamsContainerRef.current.scrollTo({ top: 0, left: 0, behavior: platform.name === 'ios' ? 'smooth' : 'instant' });
|
||||
setSelectedAddon(event.value);
|
||||
setSelectedAddon(value);
|
||||
}, [platform]);
|
||||
const showInstallAddonsButton = React.useMemo(() => {
|
||||
return !profile || profile.auth === null || profile.auth?.user?.isNewUser === true;
|
||||
}, [profile]);
|
||||
return !profile || profile.auth === null || profile.auth?.user?.isNewUser === true && !video?.upcoming;
|
||||
}, [profile, video]);
|
||||
const backButtonOnClick = React.useCallback(() => {
|
||||
if (video.deepLinks && typeof video.deepLinks.metaDetailsVideos === 'string') {
|
||||
window.location.replace(video.deepLinks.metaDetailsVideos + (
|
||||
|
|
@ -76,7 +77,6 @@ const StreamsList = ({ className, video, ...props }) => {
|
|||
}, [streamsByAddon, selectedAddon]);
|
||||
const selectableOptions = React.useMemo(() => {
|
||||
return {
|
||||
title: 'Select Addon',
|
||||
options: [
|
||||
{
|
||||
value: ALL_ADDONS_KEY,
|
||||
|
|
@ -89,10 +89,15 @@ const StreamsList = ({ className, video, ...props }) => {
|
|||
title: streamsByAddon[transportUrl].addon.manifest.name,
|
||||
}))
|
||||
],
|
||||
selected: [selectedAddon],
|
||||
value: selectedAddon,
|
||||
onSelect: onAddonSelected
|
||||
};
|
||||
}, [streamsByAddon, selectedAddon]);
|
||||
|
||||
const handleEpisodePicker = React.useCallback((season, episode) => {
|
||||
onEpisodeSearch(season, episode);
|
||||
}, [onEpisodeSearch]);
|
||||
|
||||
return (
|
||||
<div className={classnames(className, styles['streams-list-container'])}>
|
||||
<div className={styles['select-choices-wrapper']}>
|
||||
|
|
@ -111,7 +116,7 @@ const StreamsList = ({ className, video, ...props }) => {
|
|||
}
|
||||
{
|
||||
Object.keys(streamsByAddon).length > 1 ?
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
{...selectableOptions}
|
||||
className={styles['select-input-container']}
|
||||
/>
|
||||
|
|
@ -122,12 +127,27 @@ const StreamsList = ({ className, video, ...props }) => {
|
|||
{
|
||||
props.streams.length === 0 ?
|
||||
<div className={styles['message-container']}>
|
||||
{
|
||||
type === 'series' ?
|
||||
<SeasonEpisodePicker className={styles['search']} onSubmit={handleEpisodePicker} />
|
||||
: null
|
||||
}
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<div className={styles['label']}>No addons were requested for streams!</div>
|
||||
</div>
|
||||
:
|
||||
props.streams.every((streams) => streams.content.type === 'Err') ?
|
||||
<div className={styles['message-container']}>
|
||||
{
|
||||
type === 'series' ?
|
||||
<SeasonEpisodePicker className={styles['search']} onSubmit={handleEpisodePicker} />
|
||||
: null
|
||||
}
|
||||
{
|
||||
video?.upcoming ?
|
||||
<div className={styles['label']}>{t('UPCOMING')}...</div>
|
||||
: null
|
||||
}
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<div className={styles['label']}>{t('NO_STREAM')}</div>
|
||||
{
|
||||
|
|
@ -193,7 +213,9 @@ const StreamsList = ({ className, video, ...props }) => {
|
|||
StreamsList.propTypes = {
|
||||
className: PropTypes.string,
|
||||
streams: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
video: PropTypes.object
|
||||
video: PropTypes.object,
|
||||
type: PropTypes.string,
|
||||
onEpisodeSearch: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = StreamsList;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@
|
|||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
|
||||
.search {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.image {
|
||||
flex: none;
|
||||
width: 10rem;
|
||||
|
|
@ -38,6 +42,7 @@
|
|||
font-size: 1.4rem;
|
||||
text-align: center;
|
||||
color: var(--primary-foreground-color);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +114,7 @@
|
|||
.select-input-container {
|
||||
min-width: 40%;
|
||||
flex-grow: 1;
|
||||
background: none;
|
||||
background-color: none;
|
||||
|
||||
&:hover, &:focus, &:global(.active) {
|
||||
background-color: var(--overlay-color);
|
||||
|
|
@ -171,6 +176,7 @@
|
|||
max-height: 3.6em;
|
||||
text-align: center;
|
||||
color: var(--primary-foreground-color);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => {
|
|||
}));
|
||||
}, [seasons]);
|
||||
const selectedSeason = React.useMemo(() => {
|
||||
return { label: String(season), value: String(season) };
|
||||
return String(season);
|
||||
}, [season]);
|
||||
const prevNextButtonOnClick = React.useCallback((event) => {
|
||||
if (typeof onSelect === 'function') {
|
||||
|
|
@ -64,7 +64,7 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => {
|
|||
className={styles['seasons-popup-label-container']}
|
||||
options={options}
|
||||
title={season > 0 ? `${t('SEASON')} ${season}` : t('SPECIAL')}
|
||||
selectedOption={selectedSeason}
|
||||
value={selectedSeason}
|
||||
onSelect={seasonOnSelect}
|
||||
/>
|
||||
<Button className={classnames(styles['next-season-button'], { 'disabled': nextDisabled })} title={'Next season'} data-action={'next'} onClick={prevNextButtonOnClick}>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const { t } = require('i18next');
|
|||
const { useServices } = require('stremio/services');
|
||||
const { Image, SearchBar, Toggle, Video } = require('stremio/components');
|
||||
const SeasonsBar = require('./SeasonsBar');
|
||||
const { default: EpisodePicker } = require('../EpisodePicker');
|
||||
const styles = require('./styles');
|
||||
|
||||
const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect, toggleNotifications }) => {
|
||||
|
|
@ -92,6 +93,15 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
|
|||
});
|
||||
};
|
||||
|
||||
const onSeasonSearch = (value) => {
|
||||
if (value) {
|
||||
seasonOnSelect({
|
||||
type: 'select',
|
||||
value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classnames(className, styles['videos-list-container'])}>
|
||||
{
|
||||
|
|
@ -110,6 +120,7 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
|
|||
:
|
||||
metaItem.content.type === 'Err' || videosForSeason.length === 0 ?
|
||||
<div className={styles['message-container']}>
|
||||
<EpisodePicker className={styles['episode-picker']} onSubmit={onSeasonSearch} />
|
||||
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
|
||||
<div className={styles['label']}>No videos found for this meta!</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,10 +13,13 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
|
||||
.episode-picker {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.image {
|
||||
flex: none;
|
||||
width: 10rem;
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ const ControlBar = ({
|
|||
:
|
||||
null
|
||||
}
|
||||
<Button className={styles['control-bar-button']} tabIndex={-1} onMouseDown={onOptionsButtonMouseDown} onClick={onToggleOptionsMenu}>
|
||||
<Button className={classnames(styles['control-bar-button'], { 'disabled': !stream })} tabIndex={-1} onMouseDown={onOptionsButtonMouseDown} onClick={onToggleOptionsMenu}>
|
||||
<Icon className={styles['icon']} name={'more-horizontal'} />
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const langs = require('langs');
|
|||
const { useTranslation } = require('react-i18next');
|
||||
const { useRouteFocused } = require('stremio-router');
|
||||
const { useServices, useGamepad } = require('stremio/services');
|
||||
const { onFileDrop, useSettings, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS } = require('stremio/common');
|
||||
const { onFileDrop, useSettings, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS, useShell } = require('stremio/common');
|
||||
const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components');
|
||||
const BufferingLoader = require('./BufferingLoader');
|
||||
const VolumeChangeIndicator = require('./VolumeChangeIndicator');
|
||||
|
|
@ -32,7 +32,8 @@ const GAMEPAD_HANDLER_ID = 'player';
|
|||
|
||||
const Player = ({ urlParams, queryParams }) => {
|
||||
const { t } = useTranslation();
|
||||
const { chromecast, shell, core } = useServices();
|
||||
const services = useServices();
|
||||
const shell = useShell();
|
||||
const gamepad = useGamepad();
|
||||
const forceTranscoding = React.useMemo(() => {
|
||||
return queryParams.has('forceTranscoding');
|
||||
|
|
@ -49,7 +50,7 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
const [seeking, setSeeking] = React.useState(false);
|
||||
|
||||
const [casting, setCasting] = React.useState(() => {
|
||||
return chromecast.active && chromecast.transport.getCastState() === cast.framework.CastState.CONNECTED;
|
||||
return services.chromecast.active && services.chromecast.transport.getCastState() === cast.framework.CastState.CONNECTED;
|
||||
});
|
||||
const playbackDevices = React.useMemo(() => streamingServer.playbackDevices !== null && streamingServer.playbackDevices.type === 'Ready' ? streamingServer.playbackDevices.content : [], [streamingServer]);
|
||||
|
||||
|
|
@ -90,6 +91,8 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
const defaultAudioTrackSelected = React.useRef(false);
|
||||
const [error, setError] = React.useState(null);
|
||||
|
||||
const isNavigating = React.useRef(false);
|
||||
|
||||
const onImplementationChanged = React.useCallback(() => {
|
||||
video.setProp('subtitlesSize', settings.subtitlesSize);
|
||||
video.setProp('subtitlesOffset', settings.subtitlesOffset);
|
||||
|
|
@ -103,7 +106,21 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
video.setProp('extraSubtitlesOutlineColor', settings.subtitlesOutlineColor);
|
||||
}, [settings.subtitlesSize, settings.subtitlesOffset, settings.subtitlesTextColor, settings.subtitlesBackgroundColor, settings.subtitlesOutlineColor]);
|
||||
|
||||
const handleNextVideoNavigation = React.useCallback((deepLinks) => {
|
||||
if (deepLinks.player) {
|
||||
isNavigating.current = true;
|
||||
window.location.replace(deepLinks.player);
|
||||
} else if (deepLinks.metaDetailsStreams) {
|
||||
isNavigating.current = true;
|
||||
window.location.replace(deepLinks.metaDetailsStreams);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onEnded = React.useCallback(() => {
|
||||
if (isNavigating.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
ended();
|
||||
if (player.nextVideo !== null) {
|
||||
onNextVideoRequested();
|
||||
|
|
@ -220,14 +237,9 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
nextVideo();
|
||||
|
||||
const deepLinks = player.nextVideo.deepLinks;
|
||||
if (deepLinks.metaDetailsStreams && deepLinks.player) {
|
||||
window.location.replace(deepLinks.metaDetailsStreams);
|
||||
window.location.href = deepLinks.player;
|
||||
} else {
|
||||
window.location.replace(deepLinks.player ?? deepLinks.metaDetailsStreams);
|
||||
}
|
||||
handleNextVideoNavigation(deepLinks);
|
||||
}
|
||||
}, [player.nextVideo]);
|
||||
}, [player.nextVideo, handleNextVideoNavigation]);
|
||||
|
||||
const onVideoClick = React.useCallback(() => {
|
||||
if (video.state.paused !== null) {
|
||||
|
|
@ -394,8 +406,8 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
null,
|
||||
seriesInfo: player.seriesInfo,
|
||||
}, {
|
||||
chromecastTransport: chromecast.active ? chromecast.transport : null,
|
||||
shellTransport: shell.active ? shell.transport : null,
|
||||
chromecastTransport: services.chromecast.active ? services.chromecast.transport : null,
|
||||
shellTransport: services.shell.active ? services.shell.transport : null,
|
||||
});
|
||||
}
|
||||
}, [streamingServer.baseUrl, player.selected, forceTranscoding, casting]);
|
||||
|
|
@ -462,6 +474,13 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
if (!defaultSubtitlesSelected.current) {
|
||||
const findTrackByLang = (tracks, lang) => tracks.find((track) => track.lang === lang || langs.where('1', track.lang)?.[2] === lang);
|
||||
|
||||
if (settings.subtitlesLanguage === null) {
|
||||
onSubtitlesTrackSelected(null);
|
||||
onExtraSubtitlesTrackSelected(null);
|
||||
defaultSubtitlesSelected.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const subtitlesTrack = findTrackByLang(video.state.subtitlesTracks, settings.subtitlesLanguage);
|
||||
const extraSubtitlesTrack = findTrackByLang(video.state.extraSubtitlesTracks, settings.subtitlesLanguage);
|
||||
|
||||
|
|
@ -516,12 +535,12 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
const toastFilter = (item) => item?.dataset?.type === 'CoreEvent';
|
||||
toast.addFilter(toastFilter);
|
||||
const onCastStateChange = () => {
|
||||
setCasting(chromecast.active && chromecast.transport.getCastState() === cast.framework.CastState.CONNECTED);
|
||||
setCasting(services.chromecast.active && services.chromecast.transport.getCastState() === cast.framework.CastState.CONNECTED);
|
||||
};
|
||||
const onChromecastServiceStateChange = () => {
|
||||
onCastStateChange();
|
||||
if (chromecast.active) {
|
||||
chromecast.transport.on(
|
||||
if (services.chromecast.active) {
|
||||
services.chromecast.transport.on(
|
||||
cast.framework.CastContextEventType.CAST_STATE_CHANGED,
|
||||
onCastStateChange
|
||||
);
|
||||
|
|
@ -532,15 +551,15 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
onPauseRequested();
|
||||
}
|
||||
};
|
||||
chromecast.on('stateChanged', onChromecastServiceStateChange);
|
||||
core.transport.on('CoreEvent', onCoreEvent);
|
||||
services.chromecast.on('stateChanged', onChromecastServiceStateChange);
|
||||
services.core.transport.on('CoreEvent', onCoreEvent);
|
||||
onChromecastServiceStateChange();
|
||||
return () => {
|
||||
toast.removeFilter(toastFilter);
|
||||
chromecast.off('stateChanged', onChromecastServiceStateChange);
|
||||
core.transport.off('CoreEvent', onCoreEvent);
|
||||
if (chromecast.active) {
|
||||
chromecast.transport.off(
|
||||
services.chromecast.off('stateChanged', onChromecastServiceStateChange);
|
||||
services.core.transport.off('CoreEvent', onCoreEvent);
|
||||
if (services.chromecast.active) {
|
||||
services.chromecast.transport.off(
|
||||
cast.framework.CastContextEventType.CAST_STATE_CHANGED,
|
||||
onCastStateChange
|
||||
);
|
||||
|
|
@ -548,6 +567,12 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (settings.pauseOnMinimize && (shell.windowClosed || shell.windowHidden)) {
|
||||
onPauseRequested();
|
||||
}
|
||||
}, [settings.pauseOnMinimize, shell.windowClosed, shell.windowHidden]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const onKeyDown = (event) => {
|
||||
switch (event.code) {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const StatisticsMenu = ({ className, peers, speed, completed, infoHash }) => {
|
|||
Completed
|
||||
</div>
|
||||
<div className={styles['value']}>
|
||||
{ completed } %
|
||||
{ Math.min(completed, 100) } %
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -19,10 +19,12 @@
|
|||
.search-content {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0 1rem;
|
||||
overflow-y: auto;
|
||||
|
||||
.search-row {
|
||||
margin: 4rem 2rem;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-hints-wrapper {
|
||||
|
|
@ -272,7 +274,7 @@
|
|||
.search-container {
|
||||
.search-content {
|
||||
.search-row {
|
||||
margin: 2rem 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.search-row-poster, .search-row-square {
|
||||
|
|
@ -285,8 +287,10 @@
|
|||
|
||||
.search-hints-wrapper {
|
||||
margin-top: 4rem;
|
||||
|
||||
.search-hints-container {
|
||||
padding: 4rem 2rem;
|
||||
|
||||
.search-hint-container {
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const { default: Icon } = require('@stremio/stremio-icons/react');
|
|||
const { useRouteFocused } = require('stremio-router');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useProfile, usePlatform, useStreamingServer, withCoreSuspender, useToast } = require('stremio/common');
|
||||
const { Button, ColorInput, MainNavBars, Multiselect, Toggle } = require('stremio/components');
|
||||
const { Button, ColorInput, MainNavBars, MultiselectMenu, Toggle } = require('stremio/components');
|
||||
const useProfileSettingsInputs = require('./useProfileSettingsInputs');
|
||||
const useStreamingServerSettingsInputs = require('./useStreamingServerSettingsInputs');
|
||||
const useDataExport = require('./useDataExport');
|
||||
|
|
@ -49,6 +49,7 @@ const Settings = () => {
|
|||
bingeWatchingToggle,
|
||||
playInBackgroundToggle,
|
||||
hardwareDecodingToggle,
|
||||
pauseOnMinimizeToggle,
|
||||
} = useProfileSettingsInputs(profile);
|
||||
const {
|
||||
streamingServerRemoteUrlInput,
|
||||
|
|
@ -314,7 +315,7 @@ const Settings = () => {
|
|||
</div>
|
||||
<Button className={classnames(styles['option-input-container'], styles['button-container'])} title={'Authenticate'} disabled={profile.auth === null} tabIndex={-1} onClick={toggleTraktOnClick}>
|
||||
<div className={styles['label']}>
|
||||
{ profile.auth !== null && profile.auth.user !== null && profile.auth.user.trakt !== null ? t('LOG_OUT') : t('SETTINGS_TRAKT_AUTHENTICATE') }
|
||||
{ isTraktAuthenticated ? t('LOG_OUT') : t('SETTINGS_TRAKT_AUTHENTICATE') }
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -324,7 +325,7 @@ const Settings = () => {
|
|||
<div className={styles['option-name-container']}>
|
||||
<div className={styles['label']}>{ t('SETTINGS_UI_LANGUAGE') }</div>
|
||||
</div>
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
|
||||
tabIndex={-1}
|
||||
{...interfaceLanguageSelect}
|
||||
|
|
@ -386,7 +387,7 @@ const Settings = () => {
|
|||
<div className={styles['option-name-container']}>
|
||||
<div className={styles['label']}>{ t('SETTINGS_SUBTITLES_LANGUAGE') }</div>
|
||||
</div>
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
|
||||
{...subtitlesLanguageSelect}
|
||||
/>
|
||||
|
|
@ -395,7 +396,7 @@ const Settings = () => {
|
|||
<div className={styles['option-name-container']}>
|
||||
<div className={styles['label']}>{ t('SETTINGS_SUBTITLES_SIZE') }</div>
|
||||
</div>
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
|
||||
{...subtitlesSizeSelect}
|
||||
/>
|
||||
|
|
@ -437,7 +438,7 @@ const Settings = () => {
|
|||
<div className={styles['option-name-container']}>
|
||||
<div className={styles['label']}>{ t('SETTINGS_DEFAULT_AUDIO_TRACK') }</div>
|
||||
</div>
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
|
||||
{...audioLanguageSelect}
|
||||
/>
|
||||
|
|
@ -462,7 +463,7 @@ const Settings = () => {
|
|||
<div className={styles['option-name-container']}>
|
||||
<div className={styles['label']}>{ t('SETTINGS_SEEK_KEY') }</div>
|
||||
</div>
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
|
||||
{...seekTimeDurationSelect}
|
||||
/>
|
||||
|
|
@ -471,7 +472,7 @@ const Settings = () => {
|
|||
<div className={styles['option-name-container']}>
|
||||
<div className={styles['label']}>{ t('SETTINGS_SEEK_KEY_SHIFT') }</div>
|
||||
</div>
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
|
||||
{...seekShortTimeDurationSelect}
|
||||
/>
|
||||
|
|
@ -506,7 +507,7 @@ const Settings = () => {
|
|||
<div className={styles['option-name-container']}>
|
||||
<div className={styles['label']}>{ t('SETTINGS_NEXT_VIDEO_POPUP_DURATION') }</div>
|
||||
</div>
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
|
||||
disabled={!profile.settings.bingeWatching}
|
||||
{...nextVideoPopupDurationSelect}
|
||||
|
|
@ -522,7 +523,7 @@ const Settings = () => {
|
|||
<div className={styles['option-name-container']}>
|
||||
<div className={styles['label']}>{ t('SETTINGS_PLAY_IN_EXTERNAL_PLAYER') }</div>
|
||||
</div>
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
|
||||
{...playInExternalPlayerSelect}
|
||||
/>
|
||||
|
|
@ -540,6 +541,18 @@ const Settings = () => {
|
|||
/>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
shell.active &&
|
||||
<div className={styles['option-container']}>
|
||||
<div className={styles['option-name-container']}>
|
||||
<div className={styles['label']}>{ t('SETTINGS_PAUSE_MINIMIZED') }</div>
|
||||
</div>
|
||||
<Toggle
|
||||
className={classnames(styles['option-input-container'], styles['toggle-container'])}
|
||||
{...pauseOnMinimizeToggle}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div ref={streamingServerSectionRef} className={styles['section-container']}>
|
||||
<div className={styles['section-title']}>{ t('SETTINGS_NAV_STREAMING') }</div>
|
||||
|
|
@ -566,7 +579,7 @@ const Settings = () => {
|
|||
<div className={styles['option-name-container']}>
|
||||
<div className={styles['label']}>{ t('SETTINGS_HTTPS_ENDPOINT') }</div>
|
||||
</div>
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
|
||||
{...remoteEndpointSelect}
|
||||
/>
|
||||
|
|
@ -580,7 +593,7 @@ const Settings = () => {
|
|||
<div className={styles['option-name-container']}>
|
||||
<div className={styles['label']}>{ t('SETTINGS_SERVER_CACHE_SIZE') }</div>
|
||||
</div>
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
|
||||
{...cacheSizeSelect}
|
||||
/>
|
||||
|
|
@ -594,7 +607,7 @@ const Settings = () => {
|
|||
<div className={styles['option-name-container']}>
|
||||
<div className={styles['label']}>{ t('SETTINGS_SERVER_TORRENT_PROFILE') }</div>
|
||||
</div>
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
|
||||
{...torrentProfileSelect}
|
||||
/>
|
||||
|
|
@ -608,7 +621,7 @@ const Settings = () => {
|
|||
<div className={styles['option-name-container']}>
|
||||
<div className={styles['label']}>{ t('SETTINGS_TRANSCODE_PROFILE') }</div>
|
||||
</div>
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
|
||||
{...transcodingProfileSelect}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -267,6 +267,11 @@
|
|||
|
||||
.option-input-container {
|
||||
padding: 1rem 1.5rem;
|
||||
|
||||
&.multiselect-container {
|
||||
padding: 0;
|
||||
background: var(--overlay-color);
|
||||
}
|
||||
|
||||
&.button-container {
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -15,17 +15,15 @@ const useProfileSettingsInputs = (profile) => {
|
|||
value: codes[0],
|
||||
label: name,
|
||||
})),
|
||||
selected: [
|
||||
interfaceLanguages.find(({ codes }) => codes[1] === profile.settings.interfaceLanguage)?.codes?.[0] || profile.settings.interfaceLanguage
|
||||
],
|
||||
onSelect: (event) => {
|
||||
value: interfaceLanguages.find(({ codes }) => codes[1] === profile.settings.interfaceLanguage)?.codes?.[0] || profile.settings.interfaceLanguage,
|
||||
onSelect: (value) => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...profile.settings,
|
||||
interfaceLanguage: event.value
|
||||
interfaceLanguage: value
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -81,19 +79,22 @@ const useProfileSettingsInputs = (profile) => {
|
|||
}), [profile.settings]);
|
||||
|
||||
const subtitlesLanguageSelect = React.useMemo(() => ({
|
||||
options: Object.keys(languageNames).map((code) => ({
|
||||
value: code,
|
||||
label: languageNames[code]
|
||||
})),
|
||||
selected: [profile.settings.subtitlesLanguage],
|
||||
onSelect: (event) => {
|
||||
options: [
|
||||
{ value: null, label: t('NONE') },
|
||||
...Object.keys(languageNames).map((code) => ({
|
||||
value: code,
|
||||
label: languageNames[code]
|
||||
}))
|
||||
],
|
||||
value: profile.settings.subtitlesLanguage,
|
||||
onSelect: (value) => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...profile.settings,
|
||||
subtitlesLanguage: event.value
|
||||
subtitlesLanguage: value
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -104,18 +105,18 @@ const useProfileSettingsInputs = (profile) => {
|
|||
value: `${size}`,
|
||||
label: `${size}%`
|
||||
})),
|
||||
selected: [`${profile.settings.subtitlesSize}`],
|
||||
renderLabelText: () => {
|
||||
value: `${profile.settings.subtitlesSize}`,
|
||||
title: () => {
|
||||
return `${profile.settings.subtitlesSize}%`;
|
||||
},
|
||||
onSelect: (event) => {
|
||||
onSelect: (value) => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...profile.settings,
|
||||
subtitlesSize: parseInt(event.value, 10)
|
||||
subtitlesSize: parseInt(value, 10)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -123,14 +124,14 @@ const useProfileSettingsInputs = (profile) => {
|
|||
}), [profile.settings]);
|
||||
const subtitlesTextColorInput = React.useMemo(() => ({
|
||||
value: profile.settings.subtitlesTextColor,
|
||||
onChange: (event) => {
|
||||
onChange: (value) => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...profile.settings,
|
||||
subtitlesTextColor: event.value
|
||||
subtitlesTextColor: value
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -138,14 +139,14 @@ const useProfileSettingsInputs = (profile) => {
|
|||
}), [profile.settings]);
|
||||
const subtitlesBackgroundColorInput = React.useMemo(() => ({
|
||||
value: profile.settings.subtitlesBackgroundColor,
|
||||
onChange: (event) => {
|
||||
onChange: (value) => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...profile.settings,
|
||||
subtitlesBackgroundColor: event.value
|
||||
subtitlesBackgroundColor: value
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -153,14 +154,14 @@ const useProfileSettingsInputs = (profile) => {
|
|||
}), [profile.settings]);
|
||||
const subtitlesOutlineColorInput = React.useMemo(() => ({
|
||||
value: profile.settings.subtitlesOutlineColor,
|
||||
onChange: (event) => {
|
||||
onChange: (value) => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...profile.settings,
|
||||
subtitlesOutlineColor: event.value
|
||||
subtitlesOutlineColor: value
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -171,15 +172,15 @@ const useProfileSettingsInputs = (profile) => {
|
|||
value: code,
|
||||
label: languageNames[code]
|
||||
})),
|
||||
selected: [profile.settings.audioLanguage],
|
||||
onSelect: (event) => {
|
||||
value: profile.settings.audioLanguage,
|
||||
onSelect: (value) => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...profile.settings,
|
||||
audioLanguage: event.value
|
||||
audioLanguage: value
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -221,18 +222,18 @@ const useProfileSettingsInputs = (profile) => {
|
|||
value: `${size}`,
|
||||
label: `${size / 1000} ${t('SECONDS')}`
|
||||
})),
|
||||
selected: [`${profile.settings.seekTimeDuration}`],
|
||||
renderLabelText: () => {
|
||||
value: `${profile.settings.seekTimeDuration}`,
|
||||
title: () => {
|
||||
return `${profile.settings.seekTimeDuration / 1000} ${t('SECONDS')}`;
|
||||
},
|
||||
onSelect: (event) => {
|
||||
onSelect: (value) => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...profile.settings,
|
||||
seekTimeDuration: parseInt(event.value, 10)
|
||||
seekTimeDuration: parseInt(value, 10)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -243,18 +244,18 @@ const useProfileSettingsInputs = (profile) => {
|
|||
value: `${size}`,
|
||||
label: `${size / 1000} ${t('SECONDS')}`
|
||||
})),
|
||||
selected: [`${profile.settings.seekShortTimeDuration}`],
|
||||
renderLabelText: () => {
|
||||
value: `${profile.settings.seekShortTimeDuration}`,
|
||||
title: () => {
|
||||
return `${profile.settings.seekShortTimeDuration / 1000} ${t('SECONDS')}`;
|
||||
},
|
||||
onSelect: (event) => {
|
||||
onSelect: (value) => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...profile.settings,
|
||||
seekShortTimeDuration: parseInt(event.value, 10)
|
||||
seekShortTimeDuration: parseInt(value, 10)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -267,19 +268,19 @@ const useProfileSettingsInputs = (profile) => {
|
|||
value,
|
||||
label: t(label),
|
||||
})),
|
||||
selected: [profile.settings.playerType],
|
||||
renderLabelText: () => {
|
||||
value: profile.settings.playerType,
|
||||
title: () => {
|
||||
const selectedOption = CONSTANTS.EXTERNAL_PLAYERS.find(({ value }) => value === profile.settings.playerType);
|
||||
return selectedOption ? t(selectedOption.label, { defaultValue: selectedOption.label }) : profile.settings.playerType;
|
||||
},
|
||||
onSelect: (event) => {
|
||||
onSelect: (value) => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...profile.settings,
|
||||
playerType: event.value
|
||||
playerType: value
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -290,21 +291,21 @@ const useProfileSettingsInputs = (profile) => {
|
|||
value: `${duration}`,
|
||||
label: duration === 0 ? 'Disabled' : `${duration / 1000} ${t('SECONDS')}`
|
||||
})),
|
||||
selected: [`${profile.settings.nextVideoNotificationDuration}`],
|
||||
renderLabelText: () => {
|
||||
value: `${profile.settings.nextVideoNotificationDuration}`,
|
||||
title: () => {
|
||||
return profile.settings.nextVideoNotificationDuration === 0 ?
|
||||
'Disabled'
|
||||
:
|
||||
`${profile.settings.nextVideoNotificationDuration / 1000} ${t('SECONDS')}`;
|
||||
},
|
||||
onSelect: (event) => {
|
||||
onSelect: (value) => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...profile.settings,
|
||||
nextVideoNotificationDuration: parseInt(event.value, 10)
|
||||
nextVideoNotificationDuration: parseInt(value, 10)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -355,6 +356,21 @@ const useProfileSettingsInputs = (profile) => {
|
|||
});
|
||||
}
|
||||
}), [profile.settings]);
|
||||
const pauseOnMinimizeToggle = React.useMemo(() => ({
|
||||
checked: profile.settings.pauseOnMinimize,
|
||||
onClick: () => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...profile.settings,
|
||||
pauseOnMinimize: !profile.settings.pauseOnMinimize,
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}), [profile.settings]);
|
||||
return {
|
||||
interfaceLanguageSelect,
|
||||
gamepadSupportToggle,
|
||||
|
|
@ -375,6 +391,7 @@ const useProfileSettingsInputs = (profile) => {
|
|||
bingeWatchingToggle,
|
||||
playInBackgroundToggle,
|
||||
hardwareDecodingToggle,
|
||||
pauseOnMinimizeToggle,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -77,15 +77,15 @@ const useStreamingServerSettingsInputs = (streamingServer) => {
|
|||
value: address,
|
||||
}))
|
||||
],
|
||||
selected: [streamingServer.settings.content.remoteHttps],
|
||||
onSelect: (event) => {
|
||||
value: streamingServer.settings.content.remoteHttps,
|
||||
onSelect: (value) => {
|
||||
core.transport.dispatch({
|
||||
action: 'StreamingServer',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...streamingServer.settings.content,
|
||||
remoteHttps: event.value,
|
||||
remoteHttps: value,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -103,18 +103,18 @@ const useStreamingServerSettingsInputs = (streamingServer) => {
|
|||
label: cacheSizeToString(size),
|
||||
value: JSON.stringify(size)
|
||||
})),
|
||||
selected: [JSON.stringify(streamingServer.settings.content.cacheSize)],
|
||||
renderLabelText: () => {
|
||||
value: JSON.stringify(streamingServer.settings.content.cacheSize),
|
||||
title: () => {
|
||||
return cacheSizeToString(streamingServer.settings.content.cacheSize);
|
||||
},
|
||||
onSelect: (event) => {
|
||||
onSelect: (value) => {
|
||||
core.transport.dispatch({
|
||||
action: 'StreamingServer',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...streamingServer.settings.content,
|
||||
cacheSize: JSON.parse(event.value),
|
||||
cacheSize: JSON.parse(value),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -152,15 +152,15 @@ const useStreamingServerSettingsInputs = (streamingServer) => {
|
|||
:
|
||||
[]
|
||||
),
|
||||
selected: [JSON.stringify(selectedTorrentProfile)],
|
||||
onSelect: (event) => {
|
||||
value: JSON.stringify(selectedTorrentProfile),
|
||||
onSelect: (value) => {
|
||||
core.transport.dispatch({
|
||||
action: 'StreamingServer',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...streamingServer.settings.content,
|
||||
...JSON.parse(event.value),
|
||||
...JSON.parse(value),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -183,15 +183,15 @@ const useStreamingServerSettingsInputs = (streamingServer) => {
|
|||
value: name,
|
||||
}))
|
||||
],
|
||||
selected: [streamingServer.settings.content.transcodeProfile],
|
||||
onSelect: (event) => {
|
||||
value: streamingServer.settings.content.transcodeProfile,
|
||||
onSelect: (value) => {
|
||||
core.transport.dispatch({
|
||||
action: 'StreamingServer',
|
||||
args: {
|
||||
action: 'UpdateSettings',
|
||||
args: {
|
||||
...streamingServer.settings.content,
|
||||
transcodeProfile: event.value,
|
||||
transcodeProfile: value,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,16 +56,6 @@ function KeyboardShortcuts() {
|
|||
window.history.back();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case 'KeyF': {
|
||||
event.preventDefault();
|
||||
if (document.fullscreenElement === document.documentElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
document.documentElement.requestFullscreen();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
src/types/models/Ctx.d.ts
vendored
2
src/types/models/Ctx.d.ts
vendored
|
|
@ -36,7 +36,7 @@ type Settings = {
|
|||
subtitlesBackgroundColor: string,
|
||||
subtitlesBold: boolean,
|
||||
subtitlesFont: string,
|
||||
subtitlesLanguage: string,
|
||||
subtitlesLanguage: string | null,
|
||||
subtitlesOffset: number,
|
||||
subtitlesOutlineColor: string,
|
||||
subtitlesSize: number,
|
||||
|
|
|
|||
Loading…
Reference in a new issue