mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-03-11 21:27:05 +00:00
Merge branch 'development' into fix/remote-https-disabled-option
This commit is contained in:
commit
a7bbcb164a
28 changed files with 612 additions and 121 deletions
13
.github/workflows/build.yml
vendored
13
.github/workflows/build.yml
vendored
|
|
@ -1,13 +1,20 @@
|
|||
name: Build
|
||||
|
||||
on:
|
||||
|
||||
push:
|
||||
branches:
|
||||
- development
|
||||
tags-ignore:
|
||||
- '**'
|
||||
pull_request:
|
||||
branches:
|
||||
- development
|
||||
# Allow manual dispatch in GH
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -24,7 +31,7 @@ jobs:
|
|||
run: npm run lint
|
||||
# Create recursivelly the destiantion dir with
|
||||
# "--parrents where no error if existing, make parent directories as needed."
|
||||
- run: mkdir -p ./build/${{ github.ref_name }}
|
||||
- run: mkdir -p ./build/${{ github.head_ref || github.ref_name }}
|
||||
- name: Deploy to GitHub Pages
|
||||
if: ${{ github.actor != 'dependabot[bot]' }}
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
|
|
@ -33,5 +40,5 @@ jobs:
|
|||
publish_dir: ./build
|
||||
# in stremio, we use `feat/features-name` or `fix/this-bug`
|
||||
# so we need a recursive creation of the destination dir
|
||||
destination_dir: ${{ github.ref_name }}
|
||||
destination_dir: ${{ github.head_ref || github.ref_name }}
|
||||
allow_empty_commit: true
|
||||
|
|
|
|||
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -1,18 +1,18 @@
|
|||
{
|
||||
"name": "stremio",
|
||||
"version": "5.0.0-beta.8",
|
||||
"version": "5.0.0-beta.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stremio",
|
||||
"version": "5.0.0-beta.8",
|
||||
"version": "5.0.0-beta.9",
|
||||
"license": "gpl-2.0",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.16.0",
|
||||
"@sentry/browser": "6.13.3",
|
||||
"@stremio/stremio-colors": "5.0.1",
|
||||
"@stremio/stremio-core-web": "0.47.2",
|
||||
"@stremio/stremio-core-web": "0.47.8",
|
||||
"@stremio/stremio-icons": "5.2.0",
|
||||
"@stremio/stremio-video": "0.0.38",
|
||||
"a-color-picker": "1.2.1",
|
||||
|
|
@ -2971,9 +2971,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stremio/stremio-core-web": {
|
||||
"version": "0.47.2",
|
||||
"resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.47.2.tgz",
|
||||
"integrity": "sha512-kJXkshXT5f5go137id9MHrVA7PfHao2pGSxfEBbMDGFCqAVfF4jRFTXmfLC0cS1R+EjYhajUrSsXnEddtb2c7g==",
|
||||
"version": "0.47.8",
|
||||
"resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.47.8.tgz",
|
||||
"integrity": "sha512-X5yKSCm5DXR7U6oIO+2kaI1q3TnaWP6df/HFa1RBi/uw+8IYk+FB8GWpryxXyisJTFiUfQgcJDIlHROauaBQkg==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.24.1"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
{
|
||||
"name": "stremio",
|
||||
"displayName": "Stremio",
|
||||
"version": "5.0.0-beta.8",
|
||||
"version": "5.0.0-beta.9",
|
||||
"author": "Smart Code OOD",
|
||||
"private": true,
|
||||
"license": "gpl-2.0",
|
||||
"scripts": {
|
||||
"start": "webpack serve --mode development",
|
||||
"start-prod": "webpack serve --mode production",
|
||||
"build": "webpack --mode production",
|
||||
"test": "jest",
|
||||
"lint": "eslint src"
|
||||
|
|
@ -15,7 +16,7 @@
|
|||
"@babel/runtime": "7.16.0",
|
||||
"@sentry/browser": "6.13.3",
|
||||
"@stremio/stremio-colors": "5.0.1",
|
||||
"@stremio/stremio-core-web": "0.47.2",
|
||||
"@stremio/stremio-core-web": "0.47.8",
|
||||
"@stremio/stremio-icons": "5.2.0",
|
||||
"@stremio/stremio-video": "0.0.38",
|
||||
"a-color-picker": "1.2.1",
|
||||
|
|
|
|||
|
|
@ -105,7 +105,9 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
|
|||
}
|
||||
}
|
||||
:
|
||||
addonDetails.remoteAddon !== null && addonDetails.remoteAddon.content.type === 'Ready' ?
|
||||
addonDetails.remoteAddon !== null &&
|
||||
addonDetails.remoteAddon.content.type === 'Ready' &&
|
||||
!addonDetails.remoteAddon.content.content.manifest.behaviorHints.configurationRequired ?
|
||||
{
|
||||
|
||||
className: styles['install-button'],
|
||||
|
|
@ -131,7 +133,7 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
|
|||
}
|
||||
:
|
||||
null;
|
||||
return toggleButton !== null ? configureButton ? [cancelButton, configureButton, toggleButton] : [cancelButton, toggleButton] : [cancelButton];
|
||||
return configureButton && toggleButton ? [cancelButton, configureButton, toggleButton] : configureButton ? [cancelButton, configureButton] : toggleButton ? [cancelButton, toggleButton] : [cancelButton];
|
||||
}, [addonDetails, onCloseRequest]);
|
||||
const modalBackground = React.useMemo(() => {
|
||||
return addonDetails.remoteAddon?.content.type === 'Ready' ? addonDetails.remoteAddon.content.content.manifest.background : null;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const classnames = require('classnames');
|
|||
const styles = require('./styles');
|
||||
const { useLongPress } = require('use-long-press');
|
||||
|
||||
const Button = React.forwardRef(({ className, href, disabled, children, onLongPress, ...props }, ref) => {
|
||||
const Button = React.forwardRef(({ className, href, disabled, children, onLongPress, onDoubleClick, ...props }, ref) => {
|
||||
const longPress = useLongPress(onLongPress, { detect: 'pointer' });
|
||||
const onKeyDown = React.useCallback((event) => {
|
||||
if (typeof props.onKeyDown === 'function') {
|
||||
|
|
@ -42,6 +42,7 @@ const Button = React.forwardRef(({ className, href, disabled, children, onLongPr
|
|||
href,
|
||||
onKeyDown,
|
||||
onMouseDown,
|
||||
onDoubleClick,
|
||||
...longPress()
|
||||
},
|
||||
children
|
||||
|
|
@ -58,6 +59,7 @@ Button.propTypes = {
|
|||
onKeyDown: PropTypes.func,
|
||||
onMouseDown: PropTypes.func,
|
||||
onLongPress: PropTypes.func,
|
||||
onDoubleClick: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = Button;
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const EXTERNAL_PLAYERS = [
|
|||
{
|
||||
label: 'EXTERNAL_PLAYER_DISABLED',
|
||||
value: null,
|
||||
platforms: ['ios', 'android', 'windows', 'linux', 'macos'],
|
||||
platforms: ['ios', 'visionos', 'android', 'windows', 'linux', 'macos'],
|
||||
},
|
||||
{
|
||||
label: 'EXTERNAL_PLAYER_ALLOW_CHOOSING',
|
||||
|
|
@ -54,7 +54,7 @@ const EXTERNAL_PLAYERS = [
|
|||
{
|
||||
label: 'VLC',
|
||||
value: 'vlc',
|
||||
platforms: ['ios', 'android'],
|
||||
platforms: ['ios', 'visionos', 'android'],
|
||||
},
|
||||
{
|
||||
label: 'MPV',
|
||||
|
|
@ -79,12 +79,17 @@ const EXTERNAL_PLAYERS = [
|
|||
{
|
||||
label: 'Outplayer',
|
||||
value: 'outplayer',
|
||||
platforms: ['ios'],
|
||||
platforms: ['ios', 'visionos'],
|
||||
},
|
||||
{
|
||||
label: 'Moonplayer (VisionOS)',
|
||||
value: 'moonplayer',
|
||||
platforms: ['visionos'],
|
||||
},
|
||||
{
|
||||
label: 'M3U Playlist',
|
||||
value: 'm3u',
|
||||
platforms: ['ios', 'android', 'windows', 'linux', 'macos'],
|
||||
platforms: ['ios', 'visionos', 'android', 'windows', 'linux', 'macos'],
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
30
src/common/MultiselectMenu/Dropdown/Dropdown.less
Normal file
30
src/common/MultiselectMenu/Dropdown/Dropdown.less
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
.dropdown {
|
||||
background: var(--modal-background-color);
|
||||
display: none;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
box-shadow: var(--outer-glow);
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
|
||||
&.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0 0.5rem;
|
||||
padding: 0.75rem;
|
||||
color: var(--primary-foreground-color);
|
||||
|
||||
.back-button-icon {
|
||||
width: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
55
src/common/MultiselectMenu/Dropdown/Dropdown.tsx
Normal file
55
src/common/MultiselectMenu/Dropdown/Dropdown.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import React from 'react';
|
||||
import Button from 'stremio/common/Button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classNames from 'classnames';
|
||||
import Option from './Option';
|
||||
import Icon from '@stremio/stremio-icons/react';
|
||||
import styles from './Dropdown.less';
|
||||
|
||||
type Props = {
|
||||
options: MultiselectMenuOption[];
|
||||
selectedOption?: MultiselectMenuOption | null;
|
||||
menuOpen: boolean | (() => void);
|
||||
level: number;
|
||||
setLevel: (level: number) => void;
|
||||
onSelect: (value: number) => void;
|
||||
};
|
||||
|
||||
const Dropdown = ({ level, setLevel, options, onSelect, selectedOption, menuOpen }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onBackButtonClick = () => {
|
||||
setLevel(level - 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames(styles['dropdown'], { [styles['open']]: menuOpen })} role={'listbox'}>
|
||||
{
|
||||
level > 0 ?
|
||||
<Button className={styles['back-button']} onClick={onBackButtonClick}>
|
||||
<Icon name={'caret-left'} className={styles['back-button-icon']} />
|
||||
{t('BACK')}
|
||||
</Button>
|
||||
: null
|
||||
}
|
||||
{
|
||||
options
|
||||
.filter((option: MultiselectMenuOption) => !option.hidden)
|
||||
.map((option: MultiselectMenuOption, index) => (
|
||||
<Option
|
||||
key={index}
|
||||
option={option}
|
||||
onSelect={onSelect}
|
||||
selectedOption={selectedOption}
|
||||
/>
|
||||
))
|
||||
|
||||
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dropdown;
|
||||
29
src/common/MultiselectMenu/Dropdown/Option/Option.less
Normal file
29
src/common/MultiselectMenu/Dropdown/Option/Option.less
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
.option {
|
||||
font-size: var(--font-size-normal);
|
||||
color: var(--primary-foreground-color);
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 1rem;
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 100%;
|
||||
margin-left: 1rem;
|
||||
background-color: var(--secondary-accent-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
}
|
||||
46
src/common/MultiselectMenu/Dropdown/Option/Option.tsx
Normal file
46
src/common/MultiselectMenu/Dropdown/Option/Option.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import Button from 'stremio/common/Button';
|
||||
import styles from './Option.less';
|
||||
import Icon from '@stremio/stremio-icons/react';
|
||||
|
||||
type Props = {
|
||||
option: MultiselectMenuOption;
|
||||
selectedOption?: MultiselectMenuOption | null;
|
||||
onSelect: (value: number) => void;
|
||||
};
|
||||
|
||||
const Option = ({ option, selectedOption, onSelect }: Props) => {
|
||||
// consider using option.id === selectedOption?.id instead
|
||||
const selected = useMemo(() => option?.value === selectedOption?.value, [option, selectedOption]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect(option.value);
|
||||
}, [onSelect, option.value]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={classNames(styles['option'], { [styles['selected']]: selected })}
|
||||
key={option.id}
|
||||
onClick={handleClick}
|
||||
aria-selected={selected}
|
||||
>
|
||||
<div className={styles['label']}>{ option.label }</div>
|
||||
{
|
||||
selected && !option.level ?
|
||||
<div className={styles['icon']} />
|
||||
: null
|
||||
|
||||
}
|
||||
{
|
||||
option.level ?
|
||||
<Icon name={'caret-right'} className={styles['option-caret']} />
|
||||
: null
|
||||
}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Option;
|
||||
5
src/common/MultiselectMenu/Dropdown/Option/index.ts
Normal file
5
src/common/MultiselectMenu/Dropdown/Option/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import Option from './Option';
|
||||
|
||||
export default Option;
|
||||
5
src/common/MultiselectMenu/Dropdown/index.ts
Normal file
5
src/common/MultiselectMenu/Dropdown/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import Dropdown from './Dropdown';
|
||||
|
||||
export default Dropdown;
|
||||
39
src/common/MultiselectMenu/MultiselectMenu.less
Normal file
39
src/common/MultiselectMenu/MultiselectMenu.less
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
@border-radius: 2.75rem;
|
||||
|
||||
.multiselect-menu {
|
||||
position: relative;
|
||||
min-width: 8.5rem;
|
||||
overflow: visible;
|
||||
border-radius: @border-radius;
|
||||
|
||||
&.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.multiselect-button {
|
||||
color: var(--primary-foreground-color);
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0 0.5rem;
|
||||
border-radius: @border-radius;
|
||||
|
||||
.icon {
|
||||
width: 1rem;
|
||||
color: var(--primary-foreground-color);
|
||||
opacity: 0.6;
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--overlay-color);
|
||||
}
|
||||
}
|
||||
57
src/common/MultiselectMenu/MultiselectMenu.tsx
Normal file
57
src/common/MultiselectMenu/MultiselectMenu.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import React from 'react';
|
||||
import Button from 'stremio/common/Button';
|
||||
import useBinaryState from 'stremio/common/useBinaryState';
|
||||
import Dropdown from './Dropdown';
|
||||
import classNames from 'classnames';
|
||||
import Icon from '@stremio/stremio-icons/react';
|
||||
import styles from './MultiselectMenu.less';
|
||||
import useOutsideClick from 'stremio/common/useOutsideClick';
|
||||
|
||||
type Props = {
|
||||
className?: string,
|
||||
title?: string;
|
||||
options: MultiselectMenuOption[];
|
||||
selectedOption?: MultiselectMenuOption;
|
||||
onSelect: (value: number) => void;
|
||||
};
|
||||
|
||||
const MultiselectMenu = ({ className, title, options, selectedOption, 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();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames(styles['multiselect-menu'], className)} ref={multiselectMenuRef}>
|
||||
<Button
|
||||
className={classNames(styles['multiselect-button'], { [styles['open']]: menuOpen })}
|
||||
onClick={toggleMenu}
|
||||
tabIndex={0}
|
||||
aria-haspopup='listbox'
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
{title}
|
||||
<Icon name={'caret-down'} className={classNames(styles['icon'], { [styles['open']]: menuOpen })} />
|
||||
</Button>
|
||||
{
|
||||
menuOpen ?
|
||||
<Dropdown
|
||||
level={level}
|
||||
setLevel={setLevel}
|
||||
options={options}
|
||||
onSelect={onOptionSelect}
|
||||
menuOpen={menuOpen}
|
||||
selectedOption={selectedOption}
|
||||
/>
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiselectMenu;
|
||||
5
src/common/MultiselectMenu/index.ts
Normal file
5
src/common/MultiselectMenu/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import MultiselectMenu from './MultiselectMenu';
|
||||
|
||||
export default MultiselectMenu;
|
||||
9
src/common/MultiselectMenu/types.d.ts
vendored
Normal file
9
src/common/MultiselectMenu/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
type MultiselectMenuOption = {
|
||||
id?: number;
|
||||
label: string;
|
||||
value: number;
|
||||
destination?: string;
|
||||
default?: boolean;
|
||||
hidden?: boolean;
|
||||
level?: MultiselectMenuOption[];
|
||||
};
|
||||
|
|
@ -15,8 +15,17 @@ const NavTabButton = ({ className, logo, icon, label, href, selected, onClick })
|
|||
:
|
||||
null
|
||||
), [icon]);
|
||||
const onDoubleClick = () => {
|
||||
const scrollableElements = document.querySelectorAll('div');
|
||||
|
||||
scrollableElements.forEach((element) => {
|
||||
if (element.scrollTop > 0) {
|
||||
element.scrollTop = 0;
|
||||
}
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Button className={classnames(className, styles['nav-tab-button-container'], { 'selected': selected })} title={label} tabIndex={-1} href={href} onClick={onClick}>
|
||||
<Button className={classnames(className, styles['nav-tab-button-container'], { 'selected': selected })} title={label} tabIndex={-1} href={href} onClick={onClick} onDoubleClick={onDoubleClick}>
|
||||
{
|
||||
typeof logo === 'string' && logo.length > 0 ?
|
||||
<Image
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const MetaPreview = require('./MetaPreview');
|
|||
const MetaRow = require('./MetaRow');
|
||||
const ModalDialog = require('./ModalDialog');
|
||||
const Multiselect = require('./Multiselect');
|
||||
const { default: MultiselectMenu } = require('./MultiselectMenu');
|
||||
const { HorizontalNavBar, VerticalNavBar } = require('./NavBar');
|
||||
const PaginationInput = require('./PaginationInput');
|
||||
const PlayIconCircleCentered = require('./PlayIconCircleCentered');
|
||||
|
|
@ -63,6 +64,7 @@ module.exports = {
|
|||
MetaRow,
|
||||
ModalDialog,
|
||||
Multiselect,
|
||||
MultiselectMenu,
|
||||
HorizontalNavBar,
|
||||
VerticalNavBar,
|
||||
PaginationInput,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
// this detects ipad properly in safari
|
||||
// while bowser does not
|
||||
function iOS() {
|
||||
const iOS = () => {
|
||||
return [
|
||||
'iPad Simulator',
|
||||
'iPhone Simulator',
|
||||
|
|
@ -11,14 +11,22 @@ function iOS() {
|
|||
'iPhone',
|
||||
'iPod'
|
||||
].includes(navigator.platform)
|
||||
|| (navigator.userAgent.includes('Mac') && 'ontouchend' in document);
|
||||
}
|
||||
|| (navigator.userAgent.includes('Mac') && 'ontouchend' in document);
|
||||
};
|
||||
|
||||
const Bowser = require('bowser');
|
||||
|
||||
const browser = Bowser.parse(window.navigator?.userAgent || '');
|
||||
|
||||
const name = iOS() ? 'ios' : (browser?.os?.name || 'unknown').toLowerCase();
|
||||
// Edge case: iPad is included in this function
|
||||
// Keep in mind maxTouchPoints for Vision Pro might change in the future
|
||||
const isVisionProUser = () => {
|
||||
const isMacintosh = navigator.userAgent.includes('Macintosh');
|
||||
const hasFiveTouchPoints = navigator.maxTouchPoints === 5;
|
||||
return isMacintosh && hasFiveTouchPoints;
|
||||
};
|
||||
|
||||
const name = isVisionProUser() ? 'visionos' : (iOS() ? 'ios' : (browser?.os?.name || 'unknown').toLowerCase());
|
||||
|
||||
module.exports = {
|
||||
name,
|
||||
|
|
|
|||
27
src/common/useOutsideClick.ts
Normal file
27
src/common/useOutsideClick.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const useOutsideClick = (callback: () => void) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
|
||||
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mouseup', handleClickOutside);
|
||||
document.addEventListener('touchend', handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', handleClickOutside);
|
||||
document.removeEventListener('touchend', handleClickOutside);
|
||||
};
|
||||
}, [callback]);
|
||||
|
||||
return ref;
|
||||
};
|
||||
|
||||
export default useOutsideClick;
|
||||
|
|
@ -82,32 +82,13 @@ const Intro = ({ queryParams }) => {
|
|||
openLoaderModal();
|
||||
getFacebookToken()
|
||||
.then((accessToken) => {
|
||||
return fetch('https://www.strem.io/fb-login-with-token/' + encodeURIComponent(accessToken))
|
||||
.then((resp) => resp.json())
|
||||
.catch(() => {
|
||||
throw new Error('Login failed at getting token from Stremio');
|
||||
})
|
||||
.then(({ user } = {}) => {
|
||||
if (!user || typeof user.email !== 'string' || typeof user.fbLoginToken !== 'string') {
|
||||
throw new Error('Login failed at getting token from Stremio');
|
||||
}
|
||||
|
||||
return {
|
||||
email: user.email,
|
||||
password: user.fbLoginToken
|
||||
};
|
||||
});
|
||||
})
|
||||
.then(({ email, password }) => {
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'Authenticate',
|
||||
args: {
|
||||
type: 'Login',
|
||||
email,
|
||||
password,
|
||||
facebook: true
|
||||
type: 'Facebook',
|
||||
token: accessToken,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ const React = require('react');
|
|||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { Button, Image, useProfile, platform, useToast } = require('stremio/common');
|
||||
const { t } = require('i18next');
|
||||
const { Button, Image, useProfile, platform, useToast, Popup, useBinaryState } = require('stremio/common');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useRouteFocused } = require('stremio-router');
|
||||
const StreamPlaceholder = require('./StreamPlaceholder');
|
||||
const styles = require('./styles');
|
||||
|
||||
|
|
@ -13,6 +15,40 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
|
|||
const profile = useProfile();
|
||||
const toast = useToast();
|
||||
const { core } = useServices();
|
||||
const routeFocused = useRouteFocused();
|
||||
|
||||
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
|
||||
|
||||
const popupLabelOnMouseUp = React.useCallback((event) => {
|
||||
if (!event.nativeEvent.togglePopupPrevented) {
|
||||
if (event.nativeEvent.ctrlKey || event.nativeEvent.button === 2) {
|
||||
event.preventDefault();
|
||||
toggleMenu();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
const popupLabelOnContextMenu = React.useCallback((event) => {
|
||||
if (!event.nativeEvent.togglePopupPrevented && !event.nativeEvent.ctrlKey) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}, [toggleMenu]);
|
||||
const popupLabelOnLongPress = React.useCallback((event) => {
|
||||
if (event.nativeEvent.pointerType !== 'mouse' && !event.nativeEvent.togglePopupPrevented) {
|
||||
toggleMenu();
|
||||
}
|
||||
}, [toggleMenu]);
|
||||
const popupMenuOnPointerDown = React.useCallback((event) => {
|
||||
event.nativeEvent.togglePopupPrevented = true;
|
||||
}, []);
|
||||
const popupMenuOnContextMenu = React.useCallback((event) => {
|
||||
event.nativeEvent.togglePopupPrevented = true;
|
||||
}, []);
|
||||
const popupMenuOnClick = React.useCallback((event) => {
|
||||
event.nativeEvent.togglePopupPrevented = true;
|
||||
}, []);
|
||||
const popupMenuOnKeyDown = React.useCallback((event) => {
|
||||
event.nativeEvent.buttonClickPrevented = true;
|
||||
}, []);
|
||||
|
||||
const href = React.useMemo(() => {
|
||||
return deepLinks ?
|
||||
|
|
@ -47,6 +83,10 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
|
|||
null;
|
||||
}, [href, deepLinks]);
|
||||
|
||||
const streamLink = React.useMemo(() => {
|
||||
return deepLinks?.externalPlayer?.download;
|
||||
}, [deepLinks]);
|
||||
|
||||
const markVideoAsWatched = React.useCallback(() => {
|
||||
if (typeof videoId === 'string') {
|
||||
core.transport.dispatch({
|
||||
|
|
@ -74,41 +114,101 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
|
|||
}
|
||||
}, [props.onClick, profile.settings, markVideoAsWatched]);
|
||||
|
||||
const copyStreamLink = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
if (streamLink) {
|
||||
navigator.clipboard.writeText(streamLink)
|
||||
.then(() => {
|
||||
toast.show({
|
||||
type: 'success',
|
||||
title: t('PLAYER_COPY_STREAM_SUCCESS'),
|
||||
timeout: 4000
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.show({
|
||||
type: 'error',
|
||||
title: t('PLAYER_COPY_STREAM_ERROR'),
|
||||
timeout: 4000,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [streamLink]);
|
||||
|
||||
const renderThumbnailFallback = React.useCallback(() => (
|
||||
<Icon className={styles['placeholder-icon']} name={'ic_broken_link'} />
|
||||
), []);
|
||||
|
||||
return (
|
||||
<Button className={classnames(className, styles['stream-container'])} title={addonName} href={href} download={download} target={target} onClick={onClick}>
|
||||
<div className={styles['info-container']}>
|
||||
const renderLabel = React.useMemo(() => function renderLabel({ className, children, ...props }) {
|
||||
return (
|
||||
<Button className={classnames(className, styles['stream-container'])} title={addonName} href={href} target={target} download={download} onClick={onClick} {...props}>
|
||||
<div className={styles['info-container']}>
|
||||
{
|
||||
typeof thumbnail === 'string' && thumbnail.length > 0 ?
|
||||
<div className={styles['thumbnail-container']} title={name || addonName}>
|
||||
<Image
|
||||
className={styles['thumbnail']}
|
||||
src={thumbnail}
|
||||
alt={' '}
|
||||
renderFallback={renderThumbnailFallback}
|
||||
/>
|
||||
</div>
|
||||
:
|
||||
<div className={styles['addon-name-container']} title={name || addonName}>
|
||||
<div className={styles['addon-name']}>{name || addonName}</div>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
progress !== null && !isNaN(progress) && progress > 0 ?
|
||||
<div className={styles['progress-bar-container']}>
|
||||
<div className={styles['progress-bar']} style={{ width: `${progress}%` }} />
|
||||
<div className={styles['progress-bar-background']} />
|
||||
</div>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
<div className={styles['description-container']} title={description}>{description}</div>
|
||||
<Icon className={styles['icon']} name={'play'} />
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}, [thumbnail, progress, addonName, name, description, href, target, download, onClick]);
|
||||
|
||||
const renderMenu = React.useMemo(() => function renderMenu() {
|
||||
return (
|
||||
<div className={styles['context-menu-content']} onPointerDown={popupMenuOnPointerDown} onContextMenu={popupMenuOnContextMenu} onClick={popupMenuOnClick} onKeyDown={popupMenuOnKeyDown}>
|
||||
<Button className={styles['context-menu-option-container']} title={t('CTX_PLAY')}>
|
||||
<div className={styles['context-menu-option-label']}>{t('CTX_PLAY')}</div>
|
||||
</Button>
|
||||
{
|
||||
typeof thumbnail === 'string' && thumbnail.length > 0 ?
|
||||
<div className={styles['thumbnail-container']} title={name || addonName}>
|
||||
<Image
|
||||
className={styles['thumbnail']}
|
||||
src={thumbnail}
|
||||
alt={' '}
|
||||
renderFallback={renderThumbnailFallback}
|
||||
/>
|
||||
</div>
|
||||
:
|
||||
<div className={styles['addon-name-container']} title={name || addonName}>
|
||||
<div className={styles['addon-name']}>{name || addonName}</div>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
progress !== null && !isNaN(progress) && progress > 0 ?
|
||||
<div className={styles['progress-bar-container']}>
|
||||
<div className={styles['progress-bar']} style={{ width: `${progress}%` }} />
|
||||
<div className={styles['progress-bar-background']} />
|
||||
</div>
|
||||
:
|
||||
null
|
||||
streamLink &&
|
||||
<Button className={styles['context-menu-option-container']} title={t('CTX_COPY_STREAM_LINK')} onClick={copyStreamLink}>
|
||||
<div className={styles['context-menu-option-label']}>{t('CTX_COPY_STREAM_LINK')}</div>
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
<div className={styles['description-container']} title={description}>{description}</div>
|
||||
<Icon className={styles['icon']} name={'play'} />
|
||||
</Button>
|
||||
);
|
||||
}, [copyStreamLink, onClick]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!routeFocused) {
|
||||
closeMenu();
|
||||
}
|
||||
}, [routeFocused]);
|
||||
|
||||
return (
|
||||
<Popup
|
||||
className={className}
|
||||
onMouseUp={popupLabelOnMouseUp}
|
||||
onLongPress={popupLabelOnLongPress}
|
||||
onContextMenu={popupLabelOnContextMenu}
|
||||
open={menuOpen}
|
||||
onCloseRequest={closeMenu}
|
||||
renderLabel={renderLabel}
|
||||
renderMenu={renderMenu}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,14 @@
|
|||
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
|
||||
@import (reference) '~stremio/common/screen-sizes.less';
|
||||
|
||||
:import('~stremio/common/Popup/styles.less') {
|
||||
context-menu-container: menu-container;
|
||||
menu-direction-top-left: menu-direction-top-left;
|
||||
menu-direction-bottom-left: menu-direction-bottom-left;
|
||||
menu-direction-top-right: menu-direction-top-right;
|
||||
menu-direction-bottom-right: menu-direction-bottom-right;
|
||||
}
|
||||
|
||||
.stream-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
|
@ -103,6 +111,33 @@
|
|||
color: var(--primary-foreground-color);
|
||||
background-color: var(--secondary-accent-color);
|
||||
}
|
||||
|
||||
.context-menu-container {
|
||||
max-width: calc(90% - 1.5rem);
|
||||
z-index: 2;
|
||||
|
||||
.context-menu-content {
|
||||
--spatial-navigation-contain: contain;
|
||||
|
||||
.context-menu-option-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
background-color: var(--overlay-color);
|
||||
}
|
||||
|
||||
.context-menu-option-label {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color:var(--primary-foreground-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: @small) {
|
||||
|
|
@ -125,6 +160,28 @@
|
|||
.addon-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-container {
|
||||
&.menu-direction-top-left,
|
||||
&.menu-direction-bottom-left {
|
||||
right: 1.5rem;
|
||||
}
|
||||
|
||||
&.menu-direction-top-right,
|
||||
&.menu-direction-bottom-right {
|
||||
left: 1.5rem;
|
||||
}
|
||||
|
||||
&.menu-direction-top-left,
|
||||
&.menu-direction-top-right {
|
||||
bottom: 90%;
|
||||
}
|
||||
|
||||
&.menu-direction-bottom-left,
|
||||
&.menu-direction-bottom-right {
|
||||
top: 90%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,17 @@ const StreamsList = ({ className, video, ...props }) => {
|
|||
setSelectedAddon(event.value);
|
||||
}, []);
|
||||
const backButtonOnClick = React.useCallback(() => {
|
||||
window.history.back();
|
||||
}, []);
|
||||
if (video.deepLinks && typeof video.deepLinks.metaDetailsVideos === 'string') {
|
||||
window.location.replace(video.deepLinks.metaDetailsVideos + (
|
||||
typeof video.season === 'number' ?
|
||||
`?${new URLSearchParams({'season': video.season})}`
|
||||
:
|
||||
null
|
||||
));
|
||||
} else {
|
||||
window.history.back();
|
||||
}
|
||||
}, [video]);
|
||||
const countLoadingAddons = React.useMemo(() => {
|
||||
return props.streams.filter((stream) => stream.content.type === 'Loading').length;
|
||||
}, [props.streams]);
|
||||
|
|
@ -78,6 +87,30 @@ const StreamsList = ({ className, video, ...props }) => {
|
|||
}, [streamsByAddon, selectedAddon]);
|
||||
return (
|
||||
<div className={classnames(className, styles['streams-list-container'])}>
|
||||
<div className={styles['select-choices-wrapper']}>
|
||||
{
|
||||
video ?
|
||||
<React.Fragment>
|
||||
<Button className={classnames(styles['button-container'], styles['back-button-container'])} tabIndex={-1} onClick={backButtonOnClick}>
|
||||
<Icon className={styles['icon']} name={'chevron-back'} />
|
||||
</Button>
|
||||
<div className={styles['episode-title']}>
|
||||
{`S${video?.season}E${video?.episode} ${(video?.title)}`}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
Object.keys(streamsByAddon).length > 1 ?
|
||||
<Multiselect
|
||||
{...selectableOptions}
|
||||
className={styles['select-input-container']}
|
||||
/>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
{
|
||||
props.streams.length === 0 ?
|
||||
<div className={styles['message-container']}>
|
||||
|
|
@ -109,30 +142,6 @@ const StreamsList = ({ className, video, ...props }) => {
|
|||
:
|
||||
null
|
||||
}
|
||||
<div className={styles['select-choices-wrapper']}>
|
||||
{
|
||||
video ?
|
||||
<React.Fragment>
|
||||
<Button className={classnames(styles['button-container'], styles['back-button-container'])} tabIndex={-1} onClick={backButtonOnClick}>
|
||||
<Icon className={styles['icon']} name={'chevron-back'} />
|
||||
</Button>
|
||||
<div className={styles['episode-title']}>
|
||||
{`S${video?.season}E${video?.episode} ${(video?.title)}`}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
:
|
||||
null
|
||||
}
|
||||
{
|
||||
Object.keys(streamsByAddon).length > 1 ?
|
||||
<Multiselect
|
||||
{...selectableOptions}
|
||||
className={styles['select-input-container']}
|
||||
/>
|
||||
:
|
||||
null
|
||||
}
|
||||
</div>
|
||||
<div className={styles['streams-container']}>
|
||||
{filteredStreams.map((stream, index) => (
|
||||
<Stream
|
||||
|
|
|
|||
|
|
@ -182,6 +182,7 @@
|
|||
|
||||
.streams-container {
|
||||
margin-top: 0;
|
||||
overflow: visible;
|
||||
scrollbar-color: @color-surface-light5-20 transparent;
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ const PropTypes = require('prop-types');
|
|||
const classnames = require('classnames');
|
||||
const { t } = require('i18next');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { Button, Multiselect } = require('stremio/common');
|
||||
const { Button } = require('stremio/common');
|
||||
const SeasonsBarPlaceholder = require('./SeasonsBarPlaceholder');
|
||||
const styles = require('./styles');
|
||||
const { MultiselectMenu } = require('stremio/common');
|
||||
|
||||
const SeasonsBar = ({ className, seasons, season, onSelect }) => {
|
||||
const options = React.useMemo(() => {
|
||||
|
|
@ -16,8 +17,8 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => {
|
|||
label: season > 0 ? `${t('SEASON')} ${season}` : t('SPECIAL')
|
||||
}));
|
||||
}, [seasons]);
|
||||
const selected = React.useMemo(() => {
|
||||
return [String(season)];
|
||||
const selectedSeason = React.useMemo(() => {
|
||||
return { label: String(season), value: String(season) };
|
||||
}, [season]);
|
||||
const prevNextButtonOnClick = React.useCallback((event) => {
|
||||
if (typeof onSelect === 'function') {
|
||||
|
|
@ -35,8 +36,7 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => {
|
|||
});
|
||||
}
|
||||
}, [season, seasons, onSelect]);
|
||||
const seasonOnSelect = React.useCallback((event) => {
|
||||
const value = parseFloat(event.value);
|
||||
const seasonOnSelect = React.useCallback((value) => {
|
||||
if (typeof onSelect === 'function') {
|
||||
onSelect({
|
||||
type: 'select',
|
||||
|
|
@ -61,12 +61,11 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => {
|
|||
<Icon className={styles['icon']} name={'chevron-back'} />
|
||||
<div className={styles['label']}>Prev</div>
|
||||
</Button>
|
||||
<Multiselect
|
||||
<MultiselectMenu
|
||||
className={styles['seasons-popup-label-container']}
|
||||
title={season > 0 ? `${t('SEASON')} ${season}` : t('SPECIAL')}
|
||||
direction={'bottom-left'}
|
||||
options={options}
|
||||
selected={selected}
|
||||
title={season > 0 ? `${t('SEASON')} ${season}` : t('SPECIAL')}
|
||||
selectedOption={selectedSeason}
|
||||
onSelect={seasonOnSelect}
|
||||
/>
|
||||
<Button className={classnames(styles['next-season-button'], { 'disabled': nextDisabled })} title={'Next season'} data-action={'next'} onClick={prevNextButtonOnClick}>
|
||||
|
|
|
|||
|
|
@ -56,17 +56,14 @@ const Video = ({ className, id, title, thumbnail, episode, released, upcoming, w
|
|||
}
|
||||
});
|
||||
}, [id, released, watched]);
|
||||
const href = React.useMemo(() => {
|
||||
return deepLinks ?
|
||||
typeof deepLinks.player === 'string' ?
|
||||
deepLinks.player
|
||||
:
|
||||
typeof deepLinks.metaDetailsStreams === 'string' ?
|
||||
deepLinks.metaDetailsStreams
|
||||
:
|
||||
null
|
||||
:
|
||||
null;
|
||||
const videoButtonOnClick = React.useCallback(() => {
|
||||
if (deepLinks) {
|
||||
if (typeof deepLinks.player === 'string') {
|
||||
window.location = deepLinks.player;
|
||||
} else if (typeof deepLinks.metaDetailsStreams === 'string') {
|
||||
window.location.replace(deepLinks.metaDetailsStreams);
|
||||
}
|
||||
}
|
||||
}, [deepLinks]);
|
||||
const renderLabel = React.useMemo(() => function renderLabel({ className, id, title, thumbnail, episode, released, upcoming, watched, progress, scheduled, children, ...props }) {
|
||||
return (
|
||||
|
|
@ -171,7 +168,7 @@ const Video = ({ className, id, title, thumbnail, episode, released, upcoming, w
|
|||
watched={watched}
|
||||
progress={progress}
|
||||
scheduled={scheduled}
|
||||
href={href}
|
||||
onClick={videoButtonOnClick}
|
||||
{...props}
|
||||
onMouseUp={popupLabelOnMouseUp}
|
||||
onLongPress={popupLabelOnLongPress}
|
||||
|
|
|
|||
|
|
@ -74,5 +74,9 @@
|
|||
@media only screen and (max-width: @minimum) {
|
||||
.videos-list-container {
|
||||
overflow: visible;
|
||||
|
||||
.videos-container {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue