Merge branch 'development' into fix/remote-https-disabled-option

This commit is contained in:
Lachezar Lechev 2024-09-19 09:44:38 +03:00
commit a7bbcb164a
No known key found for this signature in database
GPG key ID: 69BDCB3ED8CE8037
28 changed files with 612 additions and 121 deletions

View file

@ -1,13 +1,20 @@
name: Build name: Build
on: on:
push: push:
branches: branches:
- development
tags-ignore:
- '**' - '**'
pull_request:
branches:
- development
# Allow manual dispatch in GH # Allow manual dispatch in GH
workflow_dispatch: workflow_dispatch:
permissions:
contents: write
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -24,7 +31,7 @@ jobs:
run: npm run lint run: npm run lint
# Create recursivelly the destiantion dir with # Create recursivelly the destiantion dir with
# "--parrents where no error if existing, make parent directories as needed." # "--parrents where no error if existing, make parent directories as needed."
- run: mkdir -p ./build/${{ github.ref_name }} - run: mkdir -p ./build/${{ github.head_ref || github.ref_name }}
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages
if: ${{ github.actor != 'dependabot[bot]' }} if: ${{ github.actor != 'dependabot[bot]' }}
uses: peaceiris/actions-gh-pages@v4 uses: peaceiris/actions-gh-pages@v4
@ -33,5 +40,5 @@ jobs:
publish_dir: ./build publish_dir: ./build
# in stremio, we use `feat/features-name` or `fix/this-bug` # in stremio, we use `feat/features-name` or `fix/this-bug`
# so we need a recursive creation of the destination dir # 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 allow_empty_commit: true

12
package-lock.json generated
View file

@ -1,18 +1,18 @@
{ {
"name": "stremio", "name": "stremio",
"version": "5.0.0-beta.8", "version": "5.0.0-beta.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "stremio", "name": "stremio",
"version": "5.0.0-beta.8", "version": "5.0.0-beta.9",
"license": "gpl-2.0", "license": "gpl-2.0",
"dependencies": { "dependencies": {
"@babel/runtime": "7.16.0", "@babel/runtime": "7.16.0",
"@sentry/browser": "6.13.3", "@sentry/browser": "6.13.3",
"@stremio/stremio-colors": "5.0.1", "@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-icons": "5.2.0",
"@stremio/stremio-video": "0.0.38", "@stremio/stremio-video": "0.0.38",
"a-color-picker": "1.2.1", "a-color-picker": "1.2.1",
@ -2971,9 +2971,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@stremio/stremio-core-web": { "node_modules/@stremio/stremio-core-web": {
"version": "0.47.2", "version": "0.47.8",
"resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.47.2.tgz", "resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.47.8.tgz",
"integrity": "sha512-kJXkshXT5f5go137id9MHrVA7PfHao2pGSxfEBbMDGFCqAVfF4jRFTXmfLC0cS1R+EjYhajUrSsXnEddtb2c7g==", "integrity": "sha512-X5yKSCm5DXR7U6oIO+2kaI1q3TnaWP6df/HFa1RBi/uw+8IYk+FB8GWpryxXyisJTFiUfQgcJDIlHROauaBQkg==",
"dependencies": { "dependencies": {
"@babel/runtime": "7.24.1" "@babel/runtime": "7.24.1"
} }

View file

@ -1,12 +1,13 @@
{ {
"name": "stremio", "name": "stremio",
"displayName": "Stremio", "displayName": "Stremio",
"version": "5.0.0-beta.8", "version": "5.0.0-beta.9",
"author": "Smart Code OOD", "author": "Smart Code OOD",
"private": true, "private": true,
"license": "gpl-2.0", "license": "gpl-2.0",
"scripts": { "scripts": {
"start": "webpack serve --mode development", "start": "webpack serve --mode development",
"start-prod": "webpack serve --mode production",
"build": "webpack --mode production", "build": "webpack --mode production",
"test": "jest", "test": "jest",
"lint": "eslint src" "lint": "eslint src"
@ -15,7 +16,7 @@
"@babel/runtime": "7.16.0", "@babel/runtime": "7.16.0",
"@sentry/browser": "6.13.3", "@sentry/browser": "6.13.3",
"@stremio/stremio-colors": "5.0.1", "@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-icons": "5.2.0",
"@stremio/stremio-video": "0.0.38", "@stremio/stremio-video": "0.0.38",
"a-color-picker": "1.2.1", "a-color-picker": "1.2.1",

View file

@ -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'], className: styles['install-button'],
@ -131,7 +133,7 @@ const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
} }
: :
null; 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]); }, [addonDetails, onCloseRequest]);
const modalBackground = React.useMemo(() => { const modalBackground = React.useMemo(() => {
return addonDetails.remoteAddon?.content.type === 'Ready' ? addonDetails.remoteAddon.content.content.manifest.background : null; return addonDetails.remoteAddon?.content.type === 'Ready' ? addonDetails.remoteAddon.content.content.manifest.background : null;

View file

@ -6,7 +6,7 @@ const classnames = require('classnames');
const styles = require('./styles'); const styles = require('./styles');
const { useLongPress } = require('use-long-press'); 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 longPress = useLongPress(onLongPress, { detect: 'pointer' });
const onKeyDown = React.useCallback((event) => { const onKeyDown = React.useCallback((event) => {
if (typeof props.onKeyDown === 'function') { if (typeof props.onKeyDown === 'function') {
@ -42,6 +42,7 @@ const Button = React.forwardRef(({ className, href, disabled, children, onLongPr
href, href,
onKeyDown, onKeyDown,
onMouseDown, onMouseDown,
onDoubleClick,
...longPress() ...longPress()
}, },
children children
@ -58,6 +59,7 @@ Button.propTypes = {
onKeyDown: PropTypes.func, onKeyDown: PropTypes.func,
onMouseDown: PropTypes.func, onMouseDown: PropTypes.func,
onLongPress: PropTypes.func, onLongPress: PropTypes.func,
onDoubleClick: PropTypes.func
}; };
module.exports = Button; module.exports = Button;

View file

@ -44,7 +44,7 @@ const EXTERNAL_PLAYERS = [
{ {
label: 'EXTERNAL_PLAYER_DISABLED', label: 'EXTERNAL_PLAYER_DISABLED',
value: null, value: null,
platforms: ['ios', 'android', 'windows', 'linux', 'macos'], platforms: ['ios', 'visionos', 'android', 'windows', 'linux', 'macos'],
}, },
{ {
label: 'EXTERNAL_PLAYER_ALLOW_CHOOSING', label: 'EXTERNAL_PLAYER_ALLOW_CHOOSING',
@ -54,7 +54,7 @@ const EXTERNAL_PLAYERS = [
{ {
label: 'VLC', label: 'VLC',
value: 'vlc', value: 'vlc',
platforms: ['ios', 'android'], platforms: ['ios', 'visionos', 'android'],
}, },
{ {
label: 'MPV', label: 'MPV',
@ -79,12 +79,17 @@ const EXTERNAL_PLAYERS = [
{ {
label: 'Outplayer', label: 'Outplayer',
value: 'outplayer', value: 'outplayer',
platforms: ['ios'], platforms: ['ios', 'visionos'],
},
{
label: 'Moonplayer (VisionOS)',
value: 'moonplayer',
platforms: ['visionos'],
}, },
{ {
label: 'M3U Playlist', label: 'M3U Playlist',
value: 'm3u', value: 'm3u',
platforms: ['ios', 'android', 'windows', 'linux', 'macos'], platforms: ['ios', 'visionos', 'android', 'windows', 'linux', 'macos'],
}, },
]; ];

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

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

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

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

View file

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

View file

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

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

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

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

@ -0,0 +1,9 @@
type MultiselectMenuOption = {
id?: number;
label: string;
value: number;
destination?: string;
default?: boolean;
hidden?: boolean;
level?: MultiselectMenuOption[];
};

View file

@ -15,8 +15,17 @@ const NavTabButton = ({ className, logo, icon, label, href, selected, onClick })
: :
null null
), [icon]); ), [icon]);
const onDoubleClick = () => {
const scrollableElements = document.querySelectorAll('div');
scrollableElements.forEach((element) => {
if (element.scrollTop > 0) {
element.scrollTop = 0;
}
});
};
return ( 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 ? typeof logo === 'string' && logo.length > 0 ?
<Image <Image

View file

@ -15,6 +15,7 @@ const MetaPreview = require('./MetaPreview');
const MetaRow = require('./MetaRow'); const MetaRow = require('./MetaRow');
const ModalDialog = require('./ModalDialog'); const ModalDialog = require('./ModalDialog');
const Multiselect = require('./Multiselect'); const Multiselect = require('./Multiselect');
const { default: MultiselectMenu } = require('./MultiselectMenu');
const { HorizontalNavBar, VerticalNavBar } = require('./NavBar'); const { HorizontalNavBar, VerticalNavBar } = require('./NavBar');
const PaginationInput = require('./PaginationInput'); const PaginationInput = require('./PaginationInput');
const PlayIconCircleCentered = require('./PlayIconCircleCentered'); const PlayIconCircleCentered = require('./PlayIconCircleCentered');
@ -63,6 +64,7 @@ module.exports = {
MetaRow, MetaRow,
ModalDialog, ModalDialog,
Multiselect, Multiselect,
MultiselectMenu,
HorizontalNavBar, HorizontalNavBar,
VerticalNavBar, VerticalNavBar,
PaginationInput, PaginationInput,

View file

@ -1,8 +1,8 @@
// Copyright (C) 2017-2023 Smart code 203358507 // Copyright (C) 2017-2024 Smart code 203358507
// this detects ipad properly in safari // this detects ipad properly in safari
// while bowser does not // while bowser does not
function iOS() { const iOS = () => {
return [ return [
'iPad Simulator', 'iPad Simulator',
'iPhone Simulator', 'iPhone Simulator',
@ -11,14 +11,22 @@ function iOS() {
'iPhone', 'iPhone',
'iPod' 'iPod'
].includes(navigator.platform) ].includes(navigator.platform)
|| (navigator.userAgent.includes('Mac') && 'ontouchend' in document); || (navigator.userAgent.includes('Mac') && 'ontouchend' in document);
} };
const Bowser = require('bowser'); const Bowser = require('bowser');
const browser = Bowser.parse(window.navigator?.userAgent || ''); 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 = { module.exports = {
name, name,

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

View file

@ -82,32 +82,13 @@ const Intro = ({ queryParams }) => {
openLoaderModal(); openLoaderModal();
getFacebookToken() getFacebookToken()
.then((accessToken) => { .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({ core.transport.dispatch({
action: 'Ctx', action: 'Ctx',
args: { args: {
action: 'Authenticate', action: 'Authenticate',
args: { args: {
type: 'Login', type: 'Facebook',
email, token: accessToken,
password,
facebook: true
} }
} }
}); });

View file

@ -4,8 +4,10 @@ const React = require('react');
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
const classnames = require('classnames'); const classnames = require('classnames');
const { default: Icon } = require('@stremio/stremio-icons/react'); 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 { useServices } = require('stremio/services');
const { useRouteFocused } = require('stremio-router');
const StreamPlaceholder = require('./StreamPlaceholder'); const StreamPlaceholder = require('./StreamPlaceholder');
const styles = require('./styles'); const styles = require('./styles');
@ -13,6 +15,40 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
const profile = useProfile(); const profile = useProfile();
const toast = useToast(); const toast = useToast();
const { core } = useServices(); 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(() => { const href = React.useMemo(() => {
return deepLinks ? return deepLinks ?
@ -47,6 +83,10 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
null; null;
}, [href, deepLinks]); }, [href, deepLinks]);
const streamLink = React.useMemo(() => {
return deepLinks?.externalPlayer?.download;
}, [deepLinks]);
const markVideoAsWatched = React.useCallback(() => { const markVideoAsWatched = React.useCallback(() => {
if (typeof videoId === 'string') { if (typeof videoId === 'string') {
core.transport.dispatch({ core.transport.dispatch({
@ -74,41 +114,101 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
} }
}, [props.onClick, profile.settings, markVideoAsWatched]); }, [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(() => ( const renderThumbnailFallback = React.useCallback(() => (
<Icon className={styles['placeholder-icon']} name={'ic_broken_link'} /> <Icon className={styles['placeholder-icon']} name={'ic_broken_link'} />
), []); ), []);
return ( const renderLabel = React.useMemo(() => function renderLabel({ className, children, ...props }) {
<Button className={classnames(className, styles['stream-container'])} title={addonName} href={href} download={download} target={target} onClick={onClick}> return (
<div className={styles['info-container']}> <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 ? streamLink &&
<div className={styles['thumbnail-container']} title={name || addonName}> <Button className={styles['context-menu-option-container']} title={t('CTX_COPY_STREAM_LINK')} onClick={copyStreamLink}>
<Image <div className={styles['context-menu-option-label']}>{t('CTX_COPY_STREAM_LINK')}</div>
className={styles['thumbnail']} </Button>
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>
<div className={styles['description-container']} title={description}>{description}</div> );
<Icon className={styles['icon']} name={'play'} /> }, [copyStreamLink, onClick]);
</Button>
React.useEffect(() => {
if (!routeFocused) {
closeMenu();
}
}, [routeFocused]);
return (
<Popup
className={className}
onMouseUp={popupLabelOnMouseUp}
onLongPress={popupLabelOnLongPress}
onContextMenu={popupLabelOnContextMenu}
open={menuOpen}
onCloseRequest={closeMenu}
renderLabel={renderLabel}
renderMenu={renderMenu}
/>
); );
}; };

View file

@ -3,6 +3,14 @@
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less'; @import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
@import (reference) '~stremio/common/screen-sizes.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 { .stream-container {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -103,6 +111,33 @@
color: var(--primary-foreground-color); color: var(--primary-foreground-color);
background-color: var(--secondary-accent-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) { @media only screen and (max-width: @small) {
@ -125,6 +160,28 @@
.addon-name { .addon-name {
font-weight: 500; 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%;
}
} }
} }
} }

View file

@ -20,8 +20,17 @@ const StreamsList = ({ className, video, ...props }) => {
setSelectedAddon(event.value); setSelectedAddon(event.value);
}, []); }, []);
const backButtonOnClick = React.useCallback(() => { 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(() => { const countLoadingAddons = React.useMemo(() => {
return props.streams.filter((stream) => stream.content.type === 'Loading').length; return props.streams.filter((stream) => stream.content.type === 'Loading').length;
}, [props.streams]); }, [props.streams]);
@ -78,6 +87,30 @@ const StreamsList = ({ className, video, ...props }) => {
}, [streamsByAddon, selectedAddon]); }, [streamsByAddon, selectedAddon]);
return ( return (
<div className={classnames(className, styles['streams-list-container'])}> <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 ? props.streams.length === 0 ?
<div className={styles['message-container']}> <div className={styles['message-container']}>
@ -109,30 +142,6 @@ const StreamsList = ({ className, video, ...props }) => {
: :
null 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']}> <div className={styles['streams-container']}>
{filteredStreams.map((stream, index) => ( {filteredStreams.map((stream, index) => (
<Stream <Stream

View file

@ -182,6 +182,7 @@
.streams-container { .streams-container {
margin-top: 0; margin-top: 0;
overflow: visible;
scrollbar-color: @color-surface-light5-20 transparent; scrollbar-color: @color-surface-light5-20 transparent;
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {

View file

@ -5,9 +5,10 @@ const PropTypes = require('prop-types');
const classnames = require('classnames'); const classnames = require('classnames');
const { t } = require('i18next'); const { t } = require('i18next');
const { default: Icon } = require('@stremio/stremio-icons/react'); const { default: Icon } = require('@stremio/stremio-icons/react');
const { Button, Multiselect } = require('stremio/common'); const { Button } = require('stremio/common');
const SeasonsBarPlaceholder = require('./SeasonsBarPlaceholder'); const SeasonsBarPlaceholder = require('./SeasonsBarPlaceholder');
const styles = require('./styles'); const styles = require('./styles');
const { MultiselectMenu } = require('stremio/common');
const SeasonsBar = ({ className, seasons, season, onSelect }) => { const SeasonsBar = ({ className, seasons, season, onSelect }) => {
const options = React.useMemo(() => { const options = React.useMemo(() => {
@ -16,8 +17,8 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => {
label: season > 0 ? `${t('SEASON')} ${season}` : t('SPECIAL') label: season > 0 ? `${t('SEASON')} ${season}` : t('SPECIAL')
})); }));
}, [seasons]); }, [seasons]);
const selected = React.useMemo(() => { const selectedSeason = React.useMemo(() => {
return [String(season)]; return { label: String(season), value: String(season) };
}, [season]); }, [season]);
const prevNextButtonOnClick = React.useCallback((event) => { const prevNextButtonOnClick = React.useCallback((event) => {
if (typeof onSelect === 'function') { if (typeof onSelect === 'function') {
@ -35,8 +36,7 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => {
}); });
} }
}, [season, seasons, onSelect]); }, [season, seasons, onSelect]);
const seasonOnSelect = React.useCallback((event) => { const seasonOnSelect = React.useCallback((value) => {
const value = parseFloat(event.value);
if (typeof onSelect === 'function') { if (typeof onSelect === 'function') {
onSelect({ onSelect({
type: 'select', type: 'select',
@ -61,12 +61,11 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => {
<Icon className={styles['icon']} name={'chevron-back'} /> <Icon className={styles['icon']} name={'chevron-back'} />
<div className={styles['label']}>Prev</div> <div className={styles['label']}>Prev</div>
</Button> </Button>
<Multiselect <MultiselectMenu
className={styles['seasons-popup-label-container']} className={styles['seasons-popup-label-container']}
title={season > 0 ? `${t('SEASON')} ${season}` : t('SPECIAL')}
direction={'bottom-left'}
options={options} options={options}
selected={selected} title={season > 0 ? `${t('SEASON')} ${season}` : t('SPECIAL')}
selectedOption={selectedSeason}
onSelect={seasonOnSelect} onSelect={seasonOnSelect}
/> />
<Button className={classnames(styles['next-season-button'], { 'disabled': nextDisabled })} title={'Next season'} data-action={'next'} onClick={prevNextButtonOnClick}> <Button className={classnames(styles['next-season-button'], { 'disabled': nextDisabled })} title={'Next season'} data-action={'next'} onClick={prevNextButtonOnClick}>

View file

@ -56,17 +56,14 @@ const Video = ({ className, id, title, thumbnail, episode, released, upcoming, w
} }
}); });
}, [id, released, watched]); }, [id, released, watched]);
const href = React.useMemo(() => { const videoButtonOnClick = React.useCallback(() => {
return deepLinks ? if (deepLinks) {
typeof deepLinks.player === 'string' ? if (typeof deepLinks.player === 'string') {
deepLinks.player window.location = deepLinks.player;
: } else if (typeof deepLinks.metaDetailsStreams === 'string') {
typeof deepLinks.metaDetailsStreams === 'string' ? window.location.replace(deepLinks.metaDetailsStreams);
deepLinks.metaDetailsStreams }
: }
null
:
null;
}, [deepLinks]); }, [deepLinks]);
const renderLabel = React.useMemo(() => function renderLabel({ className, id, title, thumbnail, episode, released, upcoming, watched, progress, scheduled, children, ...props }) { const renderLabel = React.useMemo(() => function renderLabel({ className, id, title, thumbnail, episode, released, upcoming, watched, progress, scheduled, children, ...props }) {
return ( return (
@ -171,7 +168,7 @@ const Video = ({ className, id, title, thumbnail, episode, released, upcoming, w
watched={watched} watched={watched}
progress={progress} progress={progress}
scheduled={scheduled} scheduled={scheduled}
href={href} onClick={videoButtonOnClick}
{...props} {...props}
onMouseUp={popupLabelOnMouseUp} onMouseUp={popupLabelOnMouseUp}
onLongPress={popupLabelOnLongPress} onLongPress={popupLabelOnLongPress}

View file

@ -74,5 +74,9 @@
@media only screen and (max-width: @minimum) { @media only screen and (max-width: @minimum) {
.videos-list-container { .videos-list-container {
overflow: visible; overflow: visible;
.videos-container {
overflow: auto;
}
} }
} }