Merge branch 'development' of https://github.com/Stremio/stremio-web into feat/calendar

This commit is contained in:
Tim 2024-09-23 14:44:26 +02:00
commit 03a61006d3
34 changed files with 654 additions and 127 deletions

8
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,8 @@
version: 2
# Check for outdated actions
updates:
- package-ecosystem: "github-actions"
directory: "/"
# Check for updates every Monday
schedule:
interval: "weekly"

View file

@ -3,14 +3,28 @@ 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
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Setup node
uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Install NPM dependencies
run: npm ci
- name: Build
@ -19,10 +33,16 @@ jobs:
run: npm test
- name: Lint
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.head_ref || github.ref_name }}
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
if: github.repository == 'Stremio/stremio-web' && github.actor != 'dependabot[bot]'
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./build
destination_dir: ${{ github.ref_name }}
# in stremio, we use `feat/features-name` or `fix/this-bug`
# so we need a recursive creation of the destination dir
destination_dir: ${{ github.head_ref || github.ref_name }}
allow_empty_commit: true

View file

@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v4
- name: Install NPM dependencies
run: npm install
- name: Build
@ -19,7 +19,7 @@ jobs:
- name: Zip build artifact
run: zip -r stremio-web.zip ./build
- name: Upload build artifact to GitHub release assets
uses: svenstaro/upload-release-action@v1-release
uses: svenstaro/upload-release-action@2.9.0
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: stremio-web.zip

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
20

View file

@ -1,4 +1,4 @@
# Stremio Node 14.x
# Stremio Node 20.x
# the node version for running Stremio Web
ARG NODE_VERSION=20-alpine
FROM node:$NODE_VERSION AS base

12
package-lock.json generated
View file

@ -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",
@ -2972,9 +2972,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"
}

View file

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

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'],
@ -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;

View file

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

View file

@ -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'],
},
];

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
), [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

View file

@ -16,6 +16,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 { default: HorizontalScroll } = require('./HorizontalScroll');
const PaginationInput = require('./PaginationInput');
@ -66,6 +67,7 @@ module.exports = {
MetaRow,
ModalDialog,
Multiselect,
MultiselectMenu,
HorizontalNavBar,
HorizontalScroll,
VerticalNavBar,

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
// 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,

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();
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,
}
}
});

View file

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

View file

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

View file

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

View file

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

View file

@ -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',
@ -46,21 +46,29 @@ const SeasonsBar = ({ className, seasons, season, onSelect }) => {
});
}
}, [onSelect]);
const [prevDisabled, nextDisabled] = React.useMemo(() => {
const currentIndex = seasons.indexOf(season);
return [
currentIndex === 0,
currentIndex === seasons.length - 1
];
}, [season, seasons]);
return (
<div className={classnames(className, styles['seasons-bar-container'])}>
<Button className={styles['prev-season-button']} title={'Previous season'} data-action={'prev'} onClick={prevNextButtonOnClick}>
<Button className={classnames(styles['prev-season-button'], { 'disabled': prevDisabled })} title={'Previous season'} data-action={'prev'} onClick={prevNextButtonOnClick}>
<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={styles['next-season-button']} 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}>
<div className={styles['label']}>Next</div>
<Icon className={styles['icon']} name={'chevron-forward'} />
</Button>

View file

@ -27,6 +27,11 @@
border-radius: 3rem;
padding: 0.5rem;
&.disabled {
pointer-events: none;
opacity: 0.5;
}
&:hover, &:focus {
background-color: var(--overlay-color);
}

View file

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

View file

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

View file

@ -70,7 +70,7 @@ const useStreamingServerSettingsInputs = (streamingServer) => {
options: [
{
label: t('SETTINGS_DISABLED'),
value: null,
value: '',
},
...streamingServer.networkInfo.content.availableInterfaces.map((address) => ({
label: address,