mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-03-29 18:08:49 +00:00
Merge branch 'master' of github.com:Stremio/stremio-web into addons-screen
This commit is contained in:
commit
cd44e3d71d
33 changed files with 2917 additions and 1224 deletions
36
package.json
36
package.json
|
|
@ -17,8 +17,8 @@
|
|||
"hat": "0.0.3",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"prop-types": "15.6.2",
|
||||
"react": "16.6.0",
|
||||
"react-dom": "16.6.0",
|
||||
"react": "16.6.3",
|
||||
"react-dom": "16.6.3",
|
||||
"react-router": "4.3.1",
|
||||
"react-router-dom": "4.3.1",
|
||||
"stremio-addon-client": "git+ssh://git@github.com/Stremio/stremio-addon-client.git#v1.5.1",
|
||||
|
|
@ -30,32 +30,32 @@
|
|||
"stremio-json-data": "git+ssh://git@github.com/stremio/stremio-json-data.git#v1.2.3",
|
||||
"stremio-models": "git+ssh://git@github.com/stremio/stremio-models.git#v1.51.5",
|
||||
"stremio-official-addons": "git+ssh://git@github.com/Stremio/stremio-official-addons.git#v1.1.1",
|
||||
"stremio-translations": "git+ssh://git@github.com/Stremio/stremio-translations.git#v1.41.0"
|
||||
"stremio-translations": "git+ssh://git@github.com/Stremio/stremio-translations.git#v1.41.0",
|
||||
"vtt.js": "0.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.1.2",
|
||||
"@babel/plugin-proposal-class-properties": "7.1.0",
|
||||
"@babel/plugin-proposal-object-rest-spread": "7.0.0",
|
||||
"@babel/preset-env": "7.1.0",
|
||||
"@babel/core": "7.2.2",
|
||||
"@babel/plugin-proposal-class-properties": "7.2.1",
|
||||
"@babel/plugin-proposal-object-rest-spread": "7.2.0",
|
||||
"@babel/preset-env": "7.2.0",
|
||||
"@babel/preset-react": "7.0.0",
|
||||
"@babel/runtime": "7.1.2",
|
||||
"@storybook/addon-actions": "4.0.4",
|
||||
"@storybook/addon-links": "4.0.4",
|
||||
"@storybook/addons": "4.0.4",
|
||||
"@storybook/react": "4.0.4",
|
||||
"autoprefixer": "9.4.0",
|
||||
"@babel/runtime": "7.2.0",
|
||||
"@storybook/addon-actions": "4.1.2",
|
||||
"@storybook/addon-links": "4.1.2",
|
||||
"@storybook/addons": "4.1.2",
|
||||
"@storybook/react": "4.1.2",
|
||||
"autoprefixer": "9.4.3",
|
||||
"babel-loader": "8.0.4",
|
||||
"copy-webpack-plugin": "4.6.0",
|
||||
"css-loader": "1.0.1",
|
||||
"css-loader": "2.0.1",
|
||||
"html-webpack-plugin": "3.2.0",
|
||||
"less": "3.8.1",
|
||||
"less": "3.9.0",
|
||||
"less-loader": "4.1.0",
|
||||
"postcss-loader": "3.0.0",
|
||||
"style-loader": "0.23.1",
|
||||
"terser-webpack-plugin": "1.1.0",
|
||||
"uglifyjs-webpack-plugin": "2.0.1",
|
||||
"webpack": "4.25.1",
|
||||
"webpack": "4.27.1",
|
||||
"webpack-cli": "3.1.2",
|
||||
"webpack-dev-server": "3.1.10"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { PureComponent } from 'react';
|
||||
import React, { PureComponent, StrictMode } from 'react';
|
||||
import { Router } from 'stremio-common';
|
||||
import routerConfig from './routerConfig';
|
||||
import styles from './styles';
|
||||
|
|
@ -6,10 +6,12 @@ import styles from './styles';
|
|||
class App extends PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<Router
|
||||
routeContainerClassName={styles['route-container']}
|
||||
config={routerConfig}
|
||||
/>
|
||||
<StrictMode>
|
||||
<Router
|
||||
routeContainerClassName={styles['route-container']}
|
||||
config={routerConfig}
|
||||
/>
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@
|
|||
font-style: normal;
|
||||
}
|
||||
|
||||
:root {
|
||||
--scroll-bar-width: 8px;
|
||||
}
|
||||
|
||||
:global {
|
||||
* {
|
||||
margin: 0px;
|
||||
|
|
@ -61,6 +65,19 @@
|
|||
min-width: 1000px;
|
||||
min-height: 650px;
|
||||
font-family: 'Roboto', 'sans-serif';
|
||||
line-height: 1;
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: var(--scroll-bar-width);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-secondarylighter80);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: var(--color-backgroundlight);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
|
|
@ -84,5 +101,6 @@
|
|||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-background);
|
||||
}
|
||||
|
|
@ -5,22 +5,33 @@ import Icon from 'stremio-icons/dom';
|
|||
import styles from './styles';
|
||||
|
||||
class Checkbox extends Component {
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
shouldComponentUpdate(nextProps) {
|
||||
return nextProps.checked !== this.props.checked ||
|
||||
nextProps.enabled !== this.props.enabled;
|
||||
nextProps.disabled !== this.props.disabled ||
|
||||
nextProps.className !== this.props.className;
|
||||
}
|
||||
|
||||
onClick = () => {
|
||||
if (this.props.enabled && typeof this.props.onClick === 'function') {
|
||||
onClick = (event) => {
|
||||
event.preventDefault();
|
||||
if (typeof this.props.onClick === 'function') {
|
||||
this.props.onClick();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={classnames(styles['root'], this.props.className, { [styles['checkbox-checked']]: this.props.checked }, { [styles['checkbox-disabled']]: !this.props.enabled })}>
|
||||
<Icon className={classnames(styles['icon'])} icon={this.props.checked ? 'ic_check' : 'ic_box_empty'} />
|
||||
<input type={'checkbox'} className={styles['native-checkbox']} defaultChecked={this.props.checked} disabled={!this.props.enabled} onClick={this.onClick} />
|
||||
<div className={classnames(this.props.className, styles['checkbox-container'], { 'checked': this.props.checked }, { 'disabled': this.props.disabled })}>
|
||||
<Icon
|
||||
className={styles['icon']}
|
||||
icon={this.props.checked ? 'ic_check' : 'ic_box_empty'}
|
||||
/>
|
||||
<input
|
||||
className={styles['native-checkbox']}
|
||||
type={'checkbox'}
|
||||
disabled={this.props.disabled}
|
||||
defaultChecked={this.props.checked}
|
||||
onClick={this.onClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,13 +39,13 @@ class Checkbox extends Component {
|
|||
|
||||
Checkbox.propTypes = {
|
||||
className: PropTypes.string,
|
||||
enabled: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.bool.isRequired,
|
||||
checked: PropTypes.bool.isRequired,
|
||||
onClick: PropTypes.func
|
||||
};
|
||||
|
||||
Checkbox.defaultProps = {
|
||||
enabled: true,
|
||||
disabled: false,
|
||||
checked: false
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
.root {
|
||||
cursor: pointer;
|
||||
.checkbox-container {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--background-color);
|
||||
|
||||
&.checkbox-checked {
|
||||
background-color: var(--color-primarylight);
|
||||
|
||||
.icon {
|
||||
padding: 10%;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
|
||||
&.checkbox-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
.icon {
|
||||
height: 100%;
|
||||
fill: var(--icon-color);
|
||||
}
|
||||
|
||||
.native-checkbox {
|
||||
|
|
@ -20,14 +16,13 @@
|
|||
opacity: 0;
|
||||
height: 0;
|
||||
width: 0;
|
||||
top: -9999px;
|
||||
left: -9999px;
|
||||
top: -99999999px;
|
||||
left: -99999999px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
fill: var(--color-surfacelighter60);
|
||||
&:global(.checked) {
|
||||
.icon {
|
||||
height: 55%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ class Popup extends Component {
|
|||
this.menuBorderRightRef = React.createRef();
|
||||
this.menuBorderBottomRef = React.createRef();
|
||||
this.menuBorderLeftRef = React.createRef();
|
||||
this.hiddenBorderRef = React.createRef();
|
||||
|
||||
this.state = {
|
||||
open: false
|
||||
|
|
@ -60,7 +61,7 @@ class Popup extends Component {
|
|||
const bodyRect = document.body.getBoundingClientRect();
|
||||
const menuRect = this.menuRef.current.getBoundingClientRect();
|
||||
const labelRect = this.labelRef.current.getBoundingClientRect();
|
||||
const borderWidth = 1 / window.devicePixelRatio;
|
||||
const borderSize = parseFloat(window.getComputedStyle(this.hiddenBorderRef.current).getPropertyValue('border-top-width'));
|
||||
const labelPosition = {
|
||||
left: labelRect.x - bodyRect.x,
|
||||
top: labelRect.y - bodyRect.y,
|
||||
|
|
@ -105,23 +106,23 @@ class Popup extends Component {
|
|||
}
|
||||
|
||||
if (this.props.border) {
|
||||
this.menuBorderTopRef.current.style.height = `${borderWidth}px`;
|
||||
this.menuBorderRightRef.current.style.width = `${borderWidth}px`;
|
||||
this.menuBorderBottomRef.current.style.height = `${borderWidth}px`;
|
||||
this.menuBorderLeftRef.current.style.width = `${borderWidth}px`;
|
||||
this.labelBorderTopRef.current.style.height = `${borderWidth}px`;
|
||||
this.menuBorderTopRef.current.style.height = `${borderSize}px`;
|
||||
this.menuBorderRightRef.current.style.width = `${borderSize}px`;
|
||||
this.menuBorderBottomRef.current.style.height = `${borderSize}px`;
|
||||
this.menuBorderLeftRef.current.style.width = `${borderSize}px`;
|
||||
this.labelBorderTopRef.current.style.height = `${borderSize}px`;
|
||||
this.labelBorderTopRef.current.style.top = `${labelPosition.top}px`;
|
||||
this.labelBorderTopRef.current.style.right = `${labelPosition.right}px`;
|
||||
this.labelBorderTopRef.current.style.left = `${labelPosition.left}px`;
|
||||
this.labelBorderRightRef.current.style.width = `${borderWidth}px`;
|
||||
this.labelBorderRightRef.current.style.width = `${borderSize}px`;
|
||||
this.labelBorderRightRef.current.style.top = `${labelPosition.top}px`;
|
||||
this.labelBorderRightRef.current.style.right = `${labelPosition.right}px`;
|
||||
this.labelBorderRightRef.current.style.bottom = `${labelPosition.bottom}px`;
|
||||
this.labelBorderBottomRef.current.style.height = `${borderWidth}px`;
|
||||
this.labelBorderBottomRef.current.style.height = `${borderSize}px`;
|
||||
this.labelBorderBottomRef.current.style.right = `${labelPosition.right}px`;
|
||||
this.labelBorderBottomRef.current.style.bottom = `${labelPosition.bottom}px`;
|
||||
this.labelBorderBottomRef.current.style.left = `${labelPosition.left}px`;
|
||||
this.labelBorderLeftRef.current.style.width = `${borderWidth}px`;
|
||||
this.labelBorderLeftRef.current.style.width = `${borderSize}px`;
|
||||
this.labelBorderLeftRef.current.style.top = `${labelPosition.top}px`;
|
||||
this.labelBorderLeftRef.current.style.bottom = `${labelPosition.bottom}px`;
|
||||
this.labelBorderLeftRef.current.style.left = `${labelPosition.left}px`;
|
||||
|
|
@ -129,16 +130,16 @@ class Popup extends Component {
|
|||
if (menuDirections.top) {
|
||||
this.labelBorderTopRef.current.style.display = 'none';
|
||||
if (menuDirections.left) {
|
||||
this.menuBorderBottomRef.current.style.right = `${labelRect.width - borderWidth}px`;
|
||||
this.menuBorderBottomRef.current.style.right = `${labelRect.width - borderSize}px`;
|
||||
} else {
|
||||
this.menuBorderBottomRef.current.style.left = `${labelRect.width - borderWidth}px`;
|
||||
this.menuBorderBottomRef.current.style.left = `${labelRect.width - borderSize}px`;
|
||||
}
|
||||
} else {
|
||||
this.labelBorderBottomRef.current.style.display = 'none';
|
||||
if (menuDirections.left) {
|
||||
this.menuBorderTopRef.current.style.right = `${labelRect.width - borderWidth}px`;
|
||||
this.menuBorderTopRef.current.style.right = `${labelRect.width - borderSize}px`;
|
||||
} else {
|
||||
this.menuBorderTopRef.current.style.left = `${labelRect.width - borderWidth}px`;
|
||||
this.menuBorderTopRef.current.style.left = `${labelRect.width - borderSize}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -189,6 +190,7 @@ class Popup extends Component {
|
|||
<div ref={this.labelBorderRightRef} className={classnames(styles['border'], styles['border-right'])} />
|
||||
<div ref={this.labelBorderBottomRef} className={classnames(styles['border'], styles['border-bottom'])} />
|
||||
<div ref={this.labelBorderLeftRef} className={classnames(styles['border'], styles['border-left'])} />
|
||||
<div ref={this.hiddenBorderRef} className={classnames(styles['border'], styles['border-hidden'])} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,4 +35,9 @@
|
|||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&-hidden {
|
||||
display: none;
|
||||
border: 1px solid;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,38 +8,25 @@ class Slider extends Component {
|
|||
super(props);
|
||||
|
||||
this.orientation = props.orientation;
|
||||
this.state = {
|
||||
active: false
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return nextState.active !== this.state.active ||
|
||||
nextProps.value !== this.props.value ||
|
||||
return nextProps.value !== this.props.value ||
|
||||
nextProps.minimumValue !== this.props.minimumValue ||
|
||||
nextProps.maximumValue !== this.props.maximumValue ||
|
||||
nextProps.className !== this.props.className;
|
||||
}
|
||||
|
||||
onSlide = (...args) => {
|
||||
this.setState({ active: true });
|
||||
if (typeof this.props.onSlide === 'function') {
|
||||
this.props.onSlide(...args);
|
||||
}
|
||||
onSlide = (value) => {
|
||||
this.props.onSlide(value);
|
||||
}
|
||||
|
||||
onComplete = (...args) => {
|
||||
this.setState({ active: false });
|
||||
if (typeof this.props.onComplete === 'function') {
|
||||
this.props.onComplete(...args);
|
||||
}
|
||||
onComplete = (value) => {
|
||||
this.props.onComplete(value);
|
||||
}
|
||||
|
||||
onCancel = (...args) => {
|
||||
this.setState({ active: false });
|
||||
if (typeof this.props.onCancel === 'function') {
|
||||
this.props.onCancel(...args);
|
||||
}
|
||||
onCancel = () => {
|
||||
this.props.onCancel();
|
||||
}
|
||||
|
||||
calculateSlidingValue = ({ mouseX, mouseY, sliderElement }) => {
|
||||
|
|
@ -50,7 +37,7 @@ class Slider extends Component {
|
|||
const thumbStart = Math.min(Math.max(mouseStart - sliderStart, 0), sliderLength);
|
||||
const slidingValueCoef = this.orientation === 'horizontal' ? thumbStart / sliderLength : (sliderLength - thumbStart) / sliderLength;
|
||||
const slidingValue = slidingValueCoef * (this.props.maximumValue - this.props.minimumValue) + this.props.minimumValue;
|
||||
return Math.floor(slidingValue);
|
||||
return slidingValue;
|
||||
}
|
||||
|
||||
onStartSliding = ({ currentTarget: sliderElement, clientX: mouseX, clientY: mouseY, button }) => {
|
||||
|
|
@ -89,10 +76,12 @@ class Slider extends Component {
|
|||
|
||||
render() {
|
||||
const thumbStartProp = this.orientation === 'horizontal' ? 'left' : 'bottom';
|
||||
const thumbStart = (this.props.value - this.props.minimumValue) / (this.props.maximumValue - this.props.minimumValue);
|
||||
const trackBeforeSizeProp = this.orientation === 'horizontal' ? 'width' : 'height';
|
||||
const thumbStart = Math.min((this.props.value - this.props.minimumValue) / (this.props.maximumValue - this.props.minimumValue), 1);
|
||||
return (
|
||||
<div className={classnames(styles['slider-container'], styles[this.orientation], { [styles['active']]: this.state.active }, this.props.className)} onMouseDown={this.onStartSliding}>
|
||||
<div className={classnames(styles['slider-container'], styles[this.orientation], this.props.className)} onMouseDown={this.onStartSliding}>
|
||||
<div className={styles['track']} />
|
||||
<div className={styles['track-before']} style={{ [trackBeforeSizeProp]: `calc(100% * ${thumbStart})` }} />
|
||||
<div className={styles['thumb']} style={{ [thumbStartProp]: `calc(100% * ${thumbStart})` }} />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -105,9 +94,9 @@ Slider.propTypes = {
|
|||
minimumValue: PropTypes.number.isRequired,
|
||||
maximumValue: PropTypes.number.isRequired,
|
||||
orientation: PropTypes.oneOf(['horizontal', 'vertical']).isRequired,
|
||||
onSlide: PropTypes.func,
|
||||
onComplete: PropTypes.func,
|
||||
onCancel: PropTypes.func
|
||||
onSlide: PropTypes.func.isRequired,
|
||||
onComplete: PropTypes.func.isRequired,
|
||||
onCancel: PropTypes.func.isRequired
|
||||
};
|
||||
Slider.defaultProps = {
|
||||
value: 0,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
.slider-container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
min-width: var(--thumb-size);
|
||||
min-height: var(--thumb-size);
|
||||
cursor: pointer;
|
||||
|
||||
.track {
|
||||
|
|
@ -11,55 +10,58 @@
|
|||
background-color: var(--track-color);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
.track-before {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
background-color: var(--track-before-color);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
width: var(--thumb-size);
|
||||
height: var(--thumb-size);
|
||||
|
||||
&::after {
|
||||
display: block;
|
||||
width: var(--thumb-size);
|
||||
height: var(--thumb-size);
|
||||
border-radius: 50%;
|
||||
background-color: var(--thumb-color);
|
||||
content: "";
|
||||
}
|
||||
border-radius: 50%;
|
||||
background-color: var(--thumb-color);
|
||||
}
|
||||
|
||||
&.horizontal {
|
||||
.track {
|
||||
left: 0;
|
||||
top: calc(50% - var(--track-size) * 0.5);
|
||||
right: 0;
|
||||
top: 40%;
|
||||
bottom: 40%;
|
||||
left: 0;
|
||||
height: var(--track-size);
|
||||
}
|
||||
|
||||
.thumb::after {
|
||||
margin-left: calc(-0.5 * var(--thumb-size));
|
||||
.track-before {
|
||||
top: calc(50% - var(--track-size) * 0.5);
|
||||
left: 0;
|
||||
height: var(--track-size);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
top: calc(50% - var(--thumb-size) * 0.5);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
&.vertical {
|
||||
.track {
|
||||
left: 40%;
|
||||
right: 40%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: calc(50% - var(--track-size) * 0.1);
|
||||
width: var(--track-size);
|
||||
}
|
||||
|
||||
.thumb::after {
|
||||
margin-top: calc(0.5 * var(--thumb-size));
|
||||
}
|
||||
}
|
||||
|
||||
&:hover, &.active {
|
||||
.track {
|
||||
background-color: var(--track-active-color, var(--track-color));
|
||||
.track-before {
|
||||
bottom: 0;
|
||||
left: calc(50% - var(--track-size) * 0.1);
|
||||
width: var(--track-size);
|
||||
}
|
||||
|
||||
.thumb::after {
|
||||
background-color: var(--thumb-active-color, var(--thumb-color));
|
||||
.thumb {
|
||||
left: calc(50% - var(--thumb-size) * 0.5);
|
||||
transform: translateY(50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,16 +3,25 @@ import PropTypes from 'prop-types';
|
|||
import classnames from 'classnames';
|
||||
import Icon from 'stremio-icons/dom';
|
||||
import { Popup } from 'stremio-common';
|
||||
import TimeSlider from './TimeSlider';
|
||||
import VolumeSlider from './VolumeSlider';
|
||||
import SeekBar from './SeekBar';
|
||||
import PlayPauseButton from './PlayPauseButton';
|
||||
import VolumeBar from './VolumeBar';
|
||||
import SubtitlesPicker from './SubtitlesPicker';
|
||||
import styles from './styles';
|
||||
|
||||
const ControlBarButton = React.forwardRef(({ icon, active, onClick }, ref) => (
|
||||
<div ref={ref} className={classnames(styles['control-bar-button'], { 'active': active })} onClick={onClick}>
|
||||
<Icon className={styles['icon']} icon={icon} />
|
||||
</div>
|
||||
));
|
||||
|
||||
class ControlBar extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
sharePopupOpen: false
|
||||
sharePopupOpen: false,
|
||||
subtitlesPopupOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -22,23 +31,17 @@ class ControlBar extends Component {
|
|||
nextProps.time !== this.props.time ||
|
||||
nextProps.duration !== this.props.duration ||
|
||||
nextProps.volume !== this.props.volume ||
|
||||
nextState.sharePopupOpen !== this.state.sharePopupOpen;
|
||||
nextProps.subtitleTracks !== this.props.subtitleTracks ||
|
||||
nextProps.selectedSubtitleTrackId !== this.props.selectedSubtitleTrackId ||
|
||||
nextProps.subtitleSize !== this.props.subtitleSize ||
|
||||
nextProps.subtitleDelay !== this.props.subtitleDelay ||
|
||||
nextProps.subtitleDarkBackground !== this.props.subtitleDarkBackground ||
|
||||
nextState.sharePopupOpen !== this.state.sharePopupOpen ||
|
||||
nextState.subtitlesPopupOpen !== this.state.subtitlesPopupOpen;
|
||||
}
|
||||
|
||||
setTime = (time) => {
|
||||
this.props.setTime(time);
|
||||
}
|
||||
|
||||
setVolume = (volume) => {
|
||||
this.props.setVolume(volume);
|
||||
}
|
||||
|
||||
toogleVolumeMute = () => {
|
||||
this.props.volume === 0 ? this.props.unmute() : this.props.mute();
|
||||
}
|
||||
|
||||
onPlayPauseButtonClick = () => {
|
||||
this.props.paused ? this.props.play() : this.props.pause();
|
||||
dispatch = (...args) => {
|
||||
this.props.dispatch(...args);
|
||||
}
|
||||
|
||||
onSharePopupOpen = () => {
|
||||
|
|
@ -49,13 +52,54 @@ class ControlBar extends Component {
|
|||
this.setState({ sharePopupOpen: false });
|
||||
}
|
||||
|
||||
onSubtitlesPopupOpen = () => {
|
||||
this.setState({ subtitlesPopupOpen: true });
|
||||
}
|
||||
|
||||
onSubtitlesPopupClose = () => {
|
||||
this.setState({ subtitlesPopupOpen: false });
|
||||
}
|
||||
|
||||
renderSeekBar() {
|
||||
return (
|
||||
<SeekBar
|
||||
className={styles['seek-bar']}
|
||||
time={this.props.time}
|
||||
duration={this.props.duration}
|
||||
dispatch={this.dispatch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderPlayPauseButton() {
|
||||
return (
|
||||
<PlayPauseButton
|
||||
toggleButtonComponent={ControlBarButton}
|
||||
paused={this.props.paused}
|
||||
dispatch={this.dispatch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderVolumeBar() {
|
||||
return (
|
||||
<VolumeBar
|
||||
className={styles['volume-bar']}
|
||||
toggleButtonComponent={ControlBarButton}
|
||||
volume={this.props.volume}
|
||||
dispatch={this.dispatch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderShareButton() {
|
||||
return (
|
||||
<Popup className={styles['popup-container']} border={true} onOpen={this.onSharePopupOpen} onClose={this.onSharePopupClose}>
|
||||
<Popup className={'player-popup-container'} border={true} onOpen={this.onSharePopupOpen} onClose={this.onSharePopupClose}>
|
||||
<Popup.Label>
|
||||
<div className={classnames(styles['control-bar-button'], { [styles['active']]: this.state.sharePopupOpen })}>
|
||||
<Icon className={styles['icon']} icon={'ic_share'} />
|
||||
</div>
|
||||
<ControlBarButton
|
||||
icon={'ic_share'}
|
||||
active={this.state.sharePopupOpen}
|
||||
/>
|
||||
</Popup.Label>
|
||||
<Popup.Menu>
|
||||
<div className={classnames(styles['popup-content'], styles['share-popup-content'])} />
|
||||
|
|
@ -64,57 +108,43 @@ class ControlBar extends Component {
|
|||
);
|
||||
}
|
||||
|
||||
renderVolumeButton() {
|
||||
if (this.props.volume === null) {
|
||||
renderSubtitlesButton() {
|
||||
if (this.props.subtitleTracks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const icon = this.props.volume === 0 ? 'ic_volume0' :
|
||||
this.props.volume < 50 ? 'ic_volume1' :
|
||||
this.props.volume < 100 ? 'ic_volume2' :
|
||||
'ic_volume3';
|
||||
return (
|
||||
<div className={styles['control-bar-button']} onClick={this.toogleVolumeMute}>
|
||||
<Icon className={styles['icon']} icon={icon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderPlayPauseButton() {
|
||||
if (this.props.paused === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const icon = this.props.paused ? 'ic_play' : 'ic_pause';
|
||||
return (
|
||||
<div className={styles['control-bar-button']} onClick={this.onPlayPauseButtonClick}>
|
||||
<Icon className={styles['icon']} icon={icon} />
|
||||
</div>
|
||||
<Popup className={'player-popup-container'} border={true} onOpen={this.onSubtitlesPopupOpen} onClose={this.onSubtitlesPopupClose}>
|
||||
<Popup.Label>
|
||||
<ControlBarButton
|
||||
icon={'ic_sub'}
|
||||
active={this.state.subtitlesPopupOpen}
|
||||
/>
|
||||
</Popup.Label>
|
||||
<Popup.Menu>
|
||||
<SubtitlesPicker
|
||||
className={classnames(styles['popup-content'], styles['subtitles-popup-content'])}
|
||||
subtitleTracks={this.props.subtitleTracks}
|
||||
selectedSubtitleTrackId={this.props.selectedSubtitleTrackId}
|
||||
subtitleSize={this.props.subtitleSize}
|
||||
subtitleDelay={this.props.subtitleDelay}
|
||||
subtitleDarkBackground={this.props.subtitleDarkBackground}
|
||||
dispatch={this.dispatch}
|
||||
/>
|
||||
</Popup.Menu>
|
||||
</Popup >
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (['paused', 'time', 'duration', 'volume', 'subtitles'].every(propName => this.props[propName] === null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classnames(styles['control-bar-container'], this.props.className)}>
|
||||
<TimeSlider
|
||||
className={styles['time-slider']}
|
||||
time={this.props.time}
|
||||
duration={this.props.duration}
|
||||
setTime={this.setTime}
|
||||
/>
|
||||
{this.renderSeekBar()}
|
||||
<div className={styles['control-bar-buttons-container']}>
|
||||
{this.renderPlayPauseButton()}
|
||||
{this.renderVolumeButton()}
|
||||
<VolumeSlider
|
||||
className={styles['volume-slider']}
|
||||
volume={this.props.volume}
|
||||
setVolume={this.setVolume}
|
||||
/>
|
||||
<div className={styles['flex-spacing']} />
|
||||
{this.renderVolumeBar()}
|
||||
<div className={styles['spacing']} />
|
||||
{this.renderSubtitlesButton()}
|
||||
{this.renderShareButton()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -128,17 +158,16 @@ ControlBar.propTypes = {
|
|||
time: PropTypes.number,
|
||||
duration: PropTypes.number,
|
||||
volume: PropTypes.number,
|
||||
subtitles: PropTypes.arrayOf(PropTypes.shape({
|
||||
subtitleTracks: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
language: PropTypes.string.isRequired
|
||||
})),
|
||||
play: PropTypes.func.isRequired,
|
||||
pause: PropTypes.func.isRequired,
|
||||
setTime: PropTypes.func.isRequired,
|
||||
setVolume: PropTypes.func.isRequired,
|
||||
mute: PropTypes.func.isRequired,
|
||||
unmute: PropTypes.func.isRequired
|
||||
origin: PropTypes.string.isRequired
|
||||
})).isRequired,
|
||||
selectedSubtitleTrackId: PropTypes.string,
|
||||
subtitleSize: PropTypes.number,
|
||||
subtitleDelay: PropTypes.number,
|
||||
subtitleDarkBackground: PropTypes.bool,
|
||||
dispatch: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ControlBar;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import React, { Component } from 'react';
|
||||
|
||||
class PlayPauseButton extends Component {
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return nextProps.paused !== this.props.paused ||
|
||||
nextProps.toggleButtonComponent !== this.props.toggleButtonComponent;
|
||||
}
|
||||
|
||||
togglePaused = () => {
|
||||
this.props.dispatch('setProp', 'paused', !this.props.paused);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.paused === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const icon = this.props.paused ? 'ic_play' : 'ic_pause';
|
||||
return React.createElement(this.props.toggleButtonComponent, { icon, onClick: this.togglePaused }, null);
|
||||
}
|
||||
}
|
||||
|
||||
export default PlayPauseButton;
|
||||
3
src/routes/Player/ControlBar/PlayPauseButton/index.js
Normal file
3
src/routes/Player/ControlBar/PlayPauseButton/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import PlayPauseButton from './PlayPauseButton';
|
||||
|
||||
export default PlayPauseButton;
|
||||
|
|
@ -5,7 +5,7 @@ import debounce from 'lodash.debounce';
|
|||
import { Slider } from 'stremio-common';
|
||||
import styles from './styles';
|
||||
|
||||
class TimeSlider extends Component {
|
||||
class SeekBar extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
|
|
@ -37,7 +37,7 @@ class TimeSlider extends Component {
|
|||
onComplete = (time) => {
|
||||
this.resetTimeDebounced();
|
||||
this.setState({ time });
|
||||
this.props.setTime(time);
|
||||
this.props.dispatch('setProp', 'time', time);
|
||||
}
|
||||
|
||||
onCancel = () => {
|
||||
|
|
@ -99,7 +99,7 @@ class TimeSlider extends Component {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={classnames(styles['time-slider-container'], { [styles['active']]: this.state.time !== null }, this.props.className)}>
|
||||
<div className={classnames(styles['seek-bar-container'], { 'active': this.state.time !== null }, this.props.className)}>
|
||||
{this.renderTimeLabel()}
|
||||
{this.renderSlider()}
|
||||
{this.renderDurationLabel()}
|
||||
|
|
@ -108,11 +108,11 @@ class TimeSlider extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
TimeSlider.propTypes = {
|
||||
SeekBar.propTypes = {
|
||||
className: PropTypes.string,
|
||||
time: PropTypes.number,
|
||||
duration: PropTypes.number,
|
||||
setTime: PropTypes.func.isRequired
|
||||
dispatch: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default TimeSlider;
|
||||
export default SeekBar;
|
||||
3
src/routes/Player/ControlBar/SeekBar/index.js
Normal file
3
src/routes/Player/ControlBar/SeekBar/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import SeekBar from './SeekBar';
|
||||
|
||||
export default SeekBar;
|
||||
32
src/routes/Player/ControlBar/SeekBar/styles.less
Normal file
32
src/routes/Player/ControlBar/SeekBar/styles.less
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
.seek-bar-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.label {
|
||||
font-size: 1em;
|
||||
color: var(--color-surfacelight);
|
||||
}
|
||||
|
||||
.slider {
|
||||
--thumb-size: var(--seek-bar-thumb-size);
|
||||
--track-size: var(--seek-bar-track-size);
|
||||
--track-before-color: var(--color-primary);
|
||||
--track-color: var(--color-backgroundlighter);
|
||||
--thumb-color: transparent;
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
margin: 0 var(--seek-bar-thumb-size);
|
||||
}
|
||||
|
||||
&:hover, &:global(.active) {
|
||||
.label {
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.slider {
|
||||
--track-before-color: var(--color-primarylight);
|
||||
--thumb-color: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
235
src/routes/Player/ControlBar/SubtitlesPicker/SubtitlesPicker.js
Normal file
235
src/routes/Player/ControlBar/SubtitlesPicker/SubtitlesPicker.js
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import React, { Component, Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import Icon from 'stremio-icons/dom';
|
||||
import { Checkbox } from 'stremio-common';
|
||||
import styles from './styles';
|
||||
|
||||
const ORIGIN_PRIORITIES = {
|
||||
'LOCAL': 1,
|
||||
'EMBEDDED': 2
|
||||
};
|
||||
|
||||
const NumberInput = ({ value, unit, delta, onChange }) => {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fractionalDigits = delta.toString().split('.')[1];
|
||||
const digitsCount = typeof fractionalDigits === 'string' ? fractionalDigits.length : 0;
|
||||
return (
|
||||
<div className={styles['number-input-container']}>
|
||||
<div className={styles['number-input-button']} data-value={value - delta} onClick={onChange}>
|
||||
<Icon className={styles['number-input-icon']} icon={'ic_minus'} />
|
||||
</div>
|
||||
<div className={styles['number-input-value']}>{value.toFixed(digitsCount)}{unit}</div>
|
||||
<div className={styles['number-input-button']} data-value={value + delta} onClick={onChange}>
|
||||
<Icon className={styles['number-input-icon']} icon={'ic_plus'} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
class SubtitlesPicker extends Component {
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return nextProps.className !== this.props.className ||
|
||||
nextProps.subtitleTracks !== this.props.subtitleTracks ||
|
||||
nextProps.selectedSubtitleTrackId !== this.props.selectedSubtitleTrackId ||
|
||||
nextProps.subtitleSize !== this.props.subtitleSize ||
|
||||
nextProps.subtitleDelay !== this.props.subtitleDelay ||
|
||||
nextProps.subtitleDarkBackground !== this.props.subtitleDarkBackground;
|
||||
}
|
||||
|
||||
subtitlesComparator = (priorities) => {
|
||||
return (a, b) => {
|
||||
const valueA = priorities[a];
|
||||
const valueB = priorities[b];
|
||||
if (!isNaN(valueA) && !isNaN(valueB)) return valueA - valueB;
|
||||
if (!isNaN(valueA)) return -1;
|
||||
if (!isNaN(valueB)) return 1;
|
||||
return a - b;
|
||||
};
|
||||
}
|
||||
|
||||
toggleSubtitleEnabled = () => {
|
||||
const selectedSubtitleTrackId = this.props.selectedSubtitleTrackId === null ? this.props.subtitleTracks[0].id : null
|
||||
this.props.dispatch('setProp', 'selectedSubtitleTrackId', selectedSubtitleTrackId);
|
||||
}
|
||||
|
||||
labelOnClick = (event) => {
|
||||
const subtitleTrack = this.props.subtitleTracks.find(({ label, origin }) => {
|
||||
return label === event.currentTarget.dataset.label &&
|
||||
origin === event.currentTarget.dataset.origin;
|
||||
});
|
||||
if (subtitleTrack) {
|
||||
this.props.dispatch('setProp', 'selectedSubtitleTrackId', subtitleTrack.id);
|
||||
}
|
||||
}
|
||||
|
||||
variantOnClick = (event) => {
|
||||
this.props.dispatch('setProp', 'selectedSubtitleTrackId', event.currentTarget.dataset.trackId);
|
||||
}
|
||||
|
||||
setSubtitleSize = (event) => {
|
||||
this.props.dispatch('setProp', 'subtitleSize', event.currentTarget.dataset.value);
|
||||
}
|
||||
|
||||
setSubtitleDelay = (event) => {
|
||||
this.props.dispatch('setProp', 'subtitleDelay', event.currentTarget.dataset.value * 1000);
|
||||
}
|
||||
|
||||
toggleSubtitleDarkBackground = () => {
|
||||
this.props.dispatch('setProp', 'subtitleDarkBackground', !this.props.subtitleDarkBackground);
|
||||
}
|
||||
|
||||
renderToggleButton({ selectedTrack }) {
|
||||
return (
|
||||
<div className={styles['toggle-button-container']} onClick={this.toggleSubtitleEnabled}>
|
||||
<div className={styles['toggle-label']}>ON</div>
|
||||
<div className={styles['toggle-label']}>OFF</div>
|
||||
<div className={classnames(styles['toggle-thumb'], { [styles['on']]: selectedTrack })} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderLabelsList({ groupedTracks, selectedTrack }) {
|
||||
return (
|
||||
<div className={styles['labels-list-container']}>
|
||||
{
|
||||
Object.keys(groupedTracks)
|
||||
.sort(this.subtitlesComparator(ORIGIN_PRIORITIES))
|
||||
.map((origin) => (
|
||||
<Fragment key={origin}>
|
||||
<div className={styles['track-origin']}>{origin}</div>
|
||||
{
|
||||
Object.keys(groupedTracks[origin])
|
||||
.sort(this.subtitlesComparator(this.props.languagePriorities))
|
||||
.map((label) => {
|
||||
const selected = selectedTrack && selectedTrack.label === label && selectedTrack.origin === origin;
|
||||
return (
|
||||
<div key={label}
|
||||
className={classnames(styles['language-label'], { [styles['selected']]: selected })}
|
||||
onClick={this.labelOnClick}
|
||||
data-label={label}
|
||||
data-origin={origin}
|
||||
children={label}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Fragment>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderVariantsList({ groupedTracks, selectedTrack }) {
|
||||
if (groupedTracks[selectedTrack.origin][selectedTrack.label].length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles['variants-container']}>
|
||||
{groupedTracks[selectedTrack.origin][selectedTrack.label].map((track, index) => {
|
||||
return (
|
||||
<div key={track.id}
|
||||
className={classnames(styles['variant-button'], { [styles['selected']]: track.id === selectedTrack.id })}
|
||||
title={track.id}
|
||||
onClick={this.variantOnClick}
|
||||
data-track-id={track.id}
|
||||
children={index + 1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderDarkBackgroundToggle() {
|
||||
if (this.props.subtitleDarkBackground === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<label className={styles['background-toggle-container']}>
|
||||
<Checkbox
|
||||
className={styles['background-toggle-checkbox']}
|
||||
checked={this.props.subtitleDarkBackground}
|
||||
onClick={this.toggleSubtitleDarkBackground}
|
||||
/>
|
||||
<div className={styles['background-toggle-label']}>Dark background</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
renderPreferences({ groupedTracks, selectedTrack }) {
|
||||
if (!selectedTrack) {
|
||||
return (
|
||||
<div className={styles['preferences-container']}>
|
||||
<div className={styles['subtitles-disabled-label']}>Subtitles are disabled</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles['preferences-container']}>
|
||||
<div className={styles['preferences-title']}>Preferences</div>
|
||||
{this.renderVariantsList({ groupedTracks, selectedTrack })}
|
||||
{this.renderDarkBackgroundToggle()}
|
||||
<NumberInput
|
||||
value={this.props.subtitleSize}
|
||||
unit={'pt'}
|
||||
delta={0.5}
|
||||
onChange={this.setSubtitleSize}
|
||||
/>
|
||||
<NumberInput
|
||||
value={this.props.subtitleDelay / 1000}
|
||||
unit={'s'}
|
||||
delta={0.2}
|
||||
onChange={this.setSubtitleDelay}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const selectedTrack = this.props.subtitleTracks.find(({ id }) => id === this.props.selectedSubtitleTrackId);
|
||||
const groupedTracks = this.props.subtitleTracks.reduce((result, track) => {
|
||||
result[track.origin] = result[track.origin] || {};
|
||||
result[track.origin][track.label] = result[track.origin][track.label] || [];
|
||||
result[track.origin][track.label].push(track);
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className={classnames(this.props.className, styles['subtitles-picker-container'])}>
|
||||
{this.renderToggleButton({ selectedTrack })}
|
||||
{this.renderLabelsList({ groupedTracks, selectedTrack })}
|
||||
{this.renderPreferences({ groupedTracks, selectedTrack })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SubtitlesPicker.propTypes = {
|
||||
className: PropTypes.string,
|
||||
languagePriorities: PropTypes.objectOf(PropTypes.number).isRequired,
|
||||
subtitleTracks: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
origin: PropTypes.string.isRequired
|
||||
})).isRequired,
|
||||
selectedSubtitleTrackId: PropTypes.string,
|
||||
subtitleSize: PropTypes.number,
|
||||
subtitleDelay: PropTypes.number,
|
||||
subtitleDarkBackground: PropTypes.bool,
|
||||
dispatch: PropTypes.func.isRequired
|
||||
};
|
||||
SubtitlesPicker.defaultProps = {
|
||||
languagePriorities: Object.freeze({
|
||||
English: 1
|
||||
})
|
||||
};
|
||||
|
||||
export default SubtitlesPicker;
|
||||
3
src/routes/Player/ControlBar/SubtitlesPicker/index.js
Normal file
3
src/routes/Player/ControlBar/SubtitlesPicker/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import SubtitlesPicker from './SubtitlesPicker';
|
||||
|
||||
export default SubtitlesPicker;
|
||||
235
src/routes/Player/ControlBar/SubtitlesPicker/styles.less
Normal file
235
src/routes/Player/ControlBar/SubtitlesPicker/styles.less
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
.subtitles-picker-container {
|
||||
width: calc(var(--subtitles-picker-button-size) * 14);
|
||||
height: calc(var(--subtitles-picker-button-size) * 9);
|
||||
font-size: calc(var(--subtitles-picker-button-size) * 0.45);
|
||||
padding: calc(var(--subtitles-picker-button-size) * 0.3);
|
||||
gap: calc(var(--subtitles-picker-button-size) * 0.3);
|
||||
display: grid;
|
||||
grid-template-columns: auto calc(var(--subtitles-picker-button-size) * 8 + var(--scroll-bar-width) * 1.5);
|
||||
grid-template-rows: var(--subtitles-picker-button-size) auto;
|
||||
grid-template-areas:
|
||||
"toggle-button preferences"
|
||||
"labels-list preferences";
|
||||
|
||||
.toggle-button-container {
|
||||
grid-area: toggle-button;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
cursor: pointer;
|
||||
|
||||
.toggle-label {
|
||||
width: 40%;
|
||||
height: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-surfacelight);
|
||||
|
||||
&:first-of-type {
|
||||
margin-right: 20%;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
width: 40%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
background-color: var(--color-primarydark);
|
||||
transition: left 0.2s;
|
||||
|
||||
&.on {
|
||||
left: 60%;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.toggle-label {
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
background-color: var(--color-primarylight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.labels-list-container {
|
||||
grid-area: labels-list;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding-right: calc(var(--scroll-bar-width) * 0.5);
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
|
||||
.track-origin {
|
||||
padding: 0 0.2em;
|
||||
margin-bottom: 0.2em;
|
||||
font-size: 85%;
|
||||
font-style: italic;
|
||||
overflow-wrap: break-word;
|
||||
border-bottom: 1px solid var(--color-surfacelighter80);
|
||||
color: var(--color-surfacelighter80);
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: 0.7em;
|
||||
}
|
||||
}
|
||||
|
||||
.language-label {
|
||||
padding: 0.5em 0.3em;
|
||||
overflow-wrap: break-word;
|
||||
text-align: center;
|
||||
color: var(--color-surfacelighter);
|
||||
cursor: pointer;
|
||||
|
||||
&.selected {
|
||||
background-color: var(--color-primarydark);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primarylight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preferences-container {
|
||||
grid-area: preferences;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.subtitles-disabled-label {
|
||||
font-size: 150%;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
word-spacing: calc(var(--subtitles-picker-button-size) * 9);
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
.preferences-title {
|
||||
height: var(--subtitles-picker-button-size);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: calc(var(--subtitles-picker-button-size) * 0.3);
|
||||
font-weight: 500;
|
||||
font-size: 90%;
|
||||
color: var(--color-surfacelight);
|
||||
}
|
||||
|
||||
.variants-container {
|
||||
align-self: stretch;
|
||||
height: calc(var(--subtitles-picker-button-size) * 2.4);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
.variant-button {
|
||||
flex: none;
|
||||
min-width: var(--subtitles-picker-button-size);
|
||||
height: var(--subtitles-picker-button-size);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-surfacelighter);
|
||||
background-color: var(--color-backgroundlighter);
|
||||
|
||||
&.selected {
|
||||
background-color: var(--color-primarydark);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primarylight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.background-toggle-container {
|
||||
align-self: stretch;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
.background-toggle-checkbox {
|
||||
--icon-color: var(--color-surfacelighter);
|
||||
--background-color: transparent;
|
||||
width: calc(var(--subtitles-picker-button-size) * 0.6);
|
||||
height: calc(var(--subtitles-picker-button-size) * 0.6);
|
||||
|
||||
&:global(.checked) {
|
||||
--background-color: var(--color-primarydark);
|
||||
}
|
||||
}
|
||||
|
||||
.background-toggle-label {
|
||||
flex: 1;
|
||||
padding: 0 0.5em;
|
||||
font-size: 1em;
|
||||
line-height: 1.1em;
|
||||
max-height: 2.2em;
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.background-toggle-checkbox {
|
||||
&:global(.checked) {
|
||||
--background-color: var(--color-primarylight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.number-input-container {
|
||||
flex: 1;
|
||||
width: 60%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.number-input-button {
|
||||
width: var(--subtitles-picker-button-size);
|
||||
height: var(--subtitles-picker-button-size);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-primarydark);
|
||||
cursor: pointer;
|
||||
|
||||
.number-input-icon {
|
||||
height: 50%;
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-primarylight);
|
||||
}
|
||||
}
|
||||
|
||||
.number-input-value {
|
||||
flex: 1;
|
||||
height: var(--subtitles-picker-button-size);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-surfacelighter);
|
||||
border-style: solid;
|
||||
border-color: var(--color-primary);
|
||||
border-width: 1px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import TimeSlider from './TimeSlider';
|
||||
|
||||
export default TimeSlider;
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
.time-slider-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.label {
|
||||
font-size: calc(var(--time-slider-thumb-size) * 0.7);
|
||||
line-height: 1;
|
||||
color: var(--color-surfacelight);
|
||||
}
|
||||
|
||||
.slider {
|
||||
--thumb-size: var(--time-slider-thumb-size);
|
||||
--track-color: var(--color-primarydark);
|
||||
--thumb-color: var(--color-primary);
|
||||
--thumb-active-color: var(--color-primarylight);
|
||||
flex: 1;
|
||||
margin: 0 var(--time-slider-thumb-size);
|
||||
}
|
||||
|
||||
&:hover, &.active {
|
||||
.label {
|
||||
color: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/routes/Player/ControlBar/VolumeBar/VolumeBar.js
Normal file
88
src/routes/Player/ControlBar/VolumeBar/VolumeBar.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { Slider } from 'stremio-common';
|
||||
import styles from './styles';
|
||||
|
||||
class VolumeBar extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
volume: null
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return nextState.volume !== this.state.volume ||
|
||||
nextProps.className !== this.props.className ||
|
||||
nextProps.volume !== this.props.volume ||
|
||||
nextProps.toggleButtonComponent !== this.props.toggleButtonComponent;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.resetVolumeDebounced.cancel();
|
||||
}
|
||||
|
||||
toogleVolumeMute = () => {
|
||||
const command = this.props.volume > 0 ? 'mute' : 'unmute';
|
||||
this.props.dispatch('command', command);
|
||||
}
|
||||
|
||||
resetVolumeDebounced = debounce(() => {
|
||||
this.setState({ volume: null });
|
||||
}, 100)
|
||||
|
||||
onSlide = (volume) => {
|
||||
this.resetVolumeDebounced.cancel();
|
||||
this.setState({ volume });
|
||||
}
|
||||
|
||||
onComplete = (volume) => {
|
||||
this.resetVolumeDebounced();
|
||||
this.setState({ volume });
|
||||
this.props.dispatch('setProp', 'volume', volume);
|
||||
}
|
||||
|
||||
onCancel = () => {
|
||||
this.resetVolumeDebounced.cancel();
|
||||
this.setState({ volume: null });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.volume === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const volume = this.state.volume !== null ? this.state.volume : this.props.volume;
|
||||
const icon = volume === 0 ? 'ic_volume0' :
|
||||
volume < 30 ? 'ic_volume1' :
|
||||
volume < 70 ? 'ic_volume2' :
|
||||
'ic_volume3';
|
||||
return (
|
||||
<div className={classnames(styles['volume-bar-container'], { 'active': this.state.volume !== null }, this.props.className)}>
|
||||
{React.createElement(this.props.toggleButtonComponent, { icon, onClick: this.toogleVolumeMute }, null)}
|
||||
<Slider
|
||||
className={styles['slider']}
|
||||
value={volume}
|
||||
minimumValue={0}
|
||||
maximumValue={100}
|
||||
orientation={'horizontal'}
|
||||
onSlide={this.onSlide}
|
||||
onComplete={this.onComplete}
|
||||
onCancel={this.onCancel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
VolumeBar.propTypes = {
|
||||
className: PropTypes.string,
|
||||
volume: PropTypes.number,
|
||||
toggleButtonComponent: PropTypes.oneOfType([PropTypes.object, PropTypes.func]).isRequired,
|
||||
dispatch: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default VolumeBar;
|
||||
3
src/routes/Player/ControlBar/VolumeBar/index.js
Normal file
3
src/routes/Player/ControlBar/VolumeBar/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import VolumeBar from './VolumeBar';
|
||||
|
||||
export default VolumeBar;
|
||||
23
src/routes/Player/ControlBar/VolumeBar/styles.less
Normal file
23
src/routes/Player/ControlBar/VolumeBar/styles.less
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
.volume-bar-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.slider {
|
||||
--thumb-size: var(--volume-bar-thumb-size);
|
||||
--track-size: var(--volume-bar-track-size);
|
||||
--track-before-color: var(--color-primary);
|
||||
--track-color: var(--color-backgroundlighter);
|
||||
--thumb-color: transparent;
|
||||
flex: 1;
|
||||
align-self: stretch;
|
||||
margin: 0 calc(var(--volume-bar-thumb-size) / 2);
|
||||
}
|
||||
|
||||
&:hover, &:global(.active) {
|
||||
.slider {
|
||||
--track-before-color: var(--color-primarylight);
|
||||
--thumb-color: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { Slider } from 'stremio-common';
|
||||
import styles from './styles';
|
||||
|
||||
class VolumeSlider extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
volume: null
|
||||
};
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return nextState.volume !== this.state.volume ||
|
||||
nextProps.volume !== this.props.volume ||
|
||||
nextProps.className !== this.props.className;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.resetVolumeDebounced.cancel();
|
||||
}
|
||||
|
||||
resetVolumeDebounced = debounce(() => {
|
||||
this.setState({ volume: null });
|
||||
}, 100)
|
||||
|
||||
onSlide = (volume) => {
|
||||
this.resetVolumeDebounced.cancel();
|
||||
this.setState({ volume });
|
||||
}
|
||||
|
||||
onComplete = (volume) => {
|
||||
this.resetVolumeDebounced();
|
||||
this.setState({ volume });
|
||||
this.props.setVolume(volume);
|
||||
}
|
||||
|
||||
onCancel = () => {
|
||||
this.resetVolumeDebounced.cancel();
|
||||
this.setState({ volume: null });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.volume === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const volume = this.state.volume !== null ? this.state.volume : this.props.volume;
|
||||
return (
|
||||
<Slider
|
||||
className={classnames(styles['slider'], this.props.className)}
|
||||
value={volume}
|
||||
minimumValue={0}
|
||||
maximumValue={100}
|
||||
orientation={'horizontal'}
|
||||
onSlide={this.onSlide}
|
||||
onComplete={this.onComplete}
|
||||
onCancel={this.onCancel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
VolumeSlider.propTypes = {
|
||||
className: PropTypes.string,
|
||||
volume: PropTypes.number,
|
||||
setVolume: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default VolumeSlider;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import VolumeSlider from './VolumeSlider';
|
||||
|
||||
export default VolumeSlider;
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
.slider {
|
||||
--thumb-size: var(--volume-slider-thumb-size);
|
||||
--track-color: var(--color-surfacelight);
|
||||
--thumb-color: var(--color-surfacelight);
|
||||
--thumb-active-color: var(--color-surfacelighter);
|
||||
--track-active-color: var(--color-surfacelighter);
|
||||
margin: 0 calc(var(--volume-slider-thumb-size) * 0.5);
|
||||
}
|
||||
|
|
@ -1,27 +1,26 @@
|
|||
.control-bar-container, .popup-container {
|
||||
--control-bar-button-height: 60px;
|
||||
}
|
||||
|
||||
.control-bar-container {
|
||||
top: initial !important;
|
||||
padding: 0 calc(var(--control-bar-button-height) * 0.4);
|
||||
padding: 0 calc(var(--control-bar-button-size) * 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: stretch;
|
||||
|
||||
.time-slider {
|
||||
--time-slider-thumb-size: calc(var(--control-bar-button-height) * 0.4);
|
||||
height: var(--time-slider-thumb-size);
|
||||
width: 100%;
|
||||
.seek-bar {
|
||||
--seek-bar-thumb-size: calc(var(--control-bar-button-size) * 0.40);
|
||||
--seek-bar-track-size: calc(var(--control-bar-button-size) * 0.12);
|
||||
height: calc(var(--control-bar-button-size) * 0.6);
|
||||
font-size: calc(var(--control-bar-button-size) * 0.35);
|
||||
}
|
||||
|
||||
.control-bar-buttons-container {
|
||||
height: var(--control-bar-button-height);
|
||||
width: 100%;
|
||||
height: var(--control-bar-button-size);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.control-bar-button {
|
||||
width: var(--control-bar-button-height);
|
||||
height: var(--control-bar-button-height);
|
||||
width: var(--control-bar-button-size);
|
||||
height: var(--control-bar-button-size);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
|
@ -34,7 +33,7 @@
|
|||
overflow: visible;
|
||||
}
|
||||
|
||||
&.active {
|
||||
&:global(.active) {
|
||||
background-color: var(--color-backgrounddarker);
|
||||
|
||||
.icon {
|
||||
|
|
@ -49,27 +48,50 @@
|
|||
}
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
--volume-slider-thumb-size: calc(var(--control-bar-button-height) * 0.3);
|
||||
height: var(--volume-slider-thumb-size);
|
||||
width: calc(var(--control-bar-button-height) * 3);
|
||||
.volume-bar {
|
||||
--volume-bar-thumb-size: calc(var(--control-bar-button-size) * 0.36);
|
||||
--volume-bar-track-size: calc(var(--control-bar-button-size) * 0.10);
|
||||
height: var(--control-bar-button-size);
|
||||
width: calc(var(--control-bar-button-size) * 5);
|
||||
|
||||
&:hover, &:global(.active) {
|
||||
.control-bar-button {
|
||||
.icon {
|
||||
fill: var(--color-surfacelighter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flex-spacing {
|
||||
.spacing {
|
||||
flex: 1
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
box-shadow: 0 0 calc(var(--control-bar-button-size) * 2) calc(var(--control-bar-button-size) * 2.3) var(--color-backgrounddarker);
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
|
||||
.popup-container {
|
||||
:global(.player-popup-container) {
|
||||
--border-color: var(--color-primarylight);
|
||||
|
||||
.popup-content {
|
||||
background-color: var(--color-backgrounddark);
|
||||
|
||||
&.share-popup-content {
|
||||
width: calc(var(--control-bar-button-height) * 5);
|
||||
height: calc(var(--control-bar-button-height) * 3);
|
||||
width: calc(var(--control-bar-button-size) * 5);
|
||||
height: calc(var(--control-bar-button-size) * 3);
|
||||
}
|
||||
|
||||
&.subtitles-popup-content {
|
||||
--subtitles-picker-button-size: calc(var(--control-bar-button-size) * 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { Component, Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classnames from 'classnames';
|
||||
import Video from './Video';
|
||||
import ControlBar from './ControlBar';
|
||||
import styles from './styles';
|
||||
|
|
@ -15,7 +16,11 @@ class Player extends Component {
|
|||
time: null,
|
||||
duration: null,
|
||||
volume: null,
|
||||
subtitles: null
|
||||
subtitleTracks: [],
|
||||
selectedSubtitleTrackId: null,
|
||||
subtitleSize: null,
|
||||
subtitleDelay: null,
|
||||
subtitleDarkBackground: null
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -24,18 +29,20 @@ class Player extends Component {
|
|||
nextState.time !== this.state.time ||
|
||||
nextState.duration !== this.state.duration ||
|
||||
nextState.volume !== this.state.volume ||
|
||||
nextState.subtitles !== this.state.subtitles;
|
||||
nextState.subtitleTracks !== this.state.subtitleTracks ||
|
||||
nextState.selectedSubtitleTrackId !== this.state.selectedSubtitleTrackId ||
|
||||
nextState.subtitleSize !== this.state.subtitleSize ||
|
||||
nextState.subtitleDelay !== this.state.subtitleDelay ||
|
||||
nextState.subtitleDarkBackground !== this.state.subtitleDarkBackground;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.addExtraSubtitles([
|
||||
{
|
||||
id: 'id1',
|
||||
url: 'https://raw.githubusercontent.com/amzn/web-app-starter-kit-for-fire-tv/master/out/mrss/assets/sample_video-en.vtt',
|
||||
label: 'English (Github)',
|
||||
language: 'en'
|
||||
}
|
||||
]);
|
||||
this.dispatch('command', 'addSubtitleTracks', [{
|
||||
url: 'https://raw.githubusercontent.com/caitp/ng-media/master/example/assets/captions/bunny-en.vtt',
|
||||
origin: 'Github',
|
||||
label: 'English'
|
||||
}]);
|
||||
this.dispatch('setProp', 'selectedSubtitleTrackId', 'https://raw.githubusercontent.com/caitp/ng-media/master/example/assets/captions/bunny-en.vtt');
|
||||
}
|
||||
|
||||
onEnded = () => {
|
||||
|
|
@ -43,16 +50,6 @@ class Player extends Component {
|
|||
}
|
||||
|
||||
onError = (error) => {
|
||||
if (error.critical) {
|
||||
this.stop();
|
||||
this.setState({
|
||||
paused: null,
|
||||
time: null,
|
||||
duration: null,
|
||||
volume: null
|
||||
});
|
||||
}
|
||||
|
||||
alert(error.message);
|
||||
}
|
||||
|
||||
|
|
@ -64,36 +61,8 @@ class Player extends Component {
|
|||
this.setState({ [propName]: propValue });
|
||||
}
|
||||
|
||||
play = () => {
|
||||
this.videoRef.current && this.videoRef.current.dispatch('setProp', 'paused', false);
|
||||
}
|
||||
|
||||
pause = () => {
|
||||
this.videoRef.current && this.videoRef.current.dispatch('setProp', 'paused', true);
|
||||
}
|
||||
|
||||
setTime = (time) => {
|
||||
this.videoRef.current && this.videoRef.current.dispatch('setProp', 'time', time);
|
||||
}
|
||||
|
||||
setVolume = (volume) => {
|
||||
this.videoRef.current && this.videoRef.current.dispatch('setProp', 'volume', volume);
|
||||
}
|
||||
|
||||
mute = () => {
|
||||
this.videoRef.current && this.videoRef.current.dispatch('command', 'mute');
|
||||
}
|
||||
|
||||
unmute = () => {
|
||||
this.videoRef.current && this.videoRef.current.dispatch('command', 'unmute');
|
||||
}
|
||||
|
||||
addExtraSubtitles = (subtitles) => {
|
||||
this.videoRef.current && this.videoRef.current.dispatch('command', 'addExtraSubtitles', subtitles);
|
||||
}
|
||||
|
||||
stop = () => {
|
||||
this.videoRef.current && this.videoRef.current.dispatch('command', 'stop');
|
||||
dispatch = (...args) => {
|
||||
this.videoRef.current && this.videoRef.current.dispatch(...args);
|
||||
}
|
||||
|
||||
renderVideo() {
|
||||
|
|
@ -116,18 +85,17 @@ class Player extends Component {
|
|||
renderControlBar() {
|
||||
return (
|
||||
<ControlBar
|
||||
className={styles['layer']}
|
||||
className={classnames(styles['layer'], styles['control-bar-layer'])}
|
||||
paused={this.state.paused}
|
||||
time={this.state.time}
|
||||
duration={this.state.duration}
|
||||
volume={this.state.volume}
|
||||
subtitles={this.state.subtitles}
|
||||
play={this.play}
|
||||
pause={this.pause}
|
||||
setTime={this.setTime}
|
||||
setVolume={this.setVolume}
|
||||
mute={this.mute}
|
||||
unmute={this.unmute}
|
||||
subtitleTracks={this.state.subtitleTracks}
|
||||
selectedSubtitleTrackId={this.state.selectedSubtitleTrackId}
|
||||
subtitleSize={this.state.subtitleSize}
|
||||
subtitleDelay={this.state.subtitleDelay}
|
||||
subtitleDarkBackground={this.state.subtitleDarkBackground}
|
||||
dispatch={this.dispatch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -146,10 +114,10 @@ Player.propTypes = {
|
|||
stream: PropTypes.object.isRequired
|
||||
};
|
||||
Player.defaultProps = {
|
||||
stream: {
|
||||
stream: Object.freeze({
|
||||
// ytId: 'E4A0bcCQke0',
|
||||
url: 'http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
export default Player;
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class Video extends Component {
|
|||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.dispatch('stop');
|
||||
this.dispatch('command', 'destroy');
|
||||
}
|
||||
|
||||
selectVideoImplementation = () => {
|
||||
|
|
@ -67,7 +67,7 @@ Video.propTypes = {
|
|||
onPropChanged: PropTypes.func.isRequired
|
||||
};
|
||||
Video.defaultProps = {
|
||||
extra: {}
|
||||
extra: Object.freeze({})
|
||||
};
|
||||
|
||||
export default Video;
|
||||
|
|
|
|||
|
|
@ -1,21 +1,102 @@
|
|||
var EventEmitter = require('events');
|
||||
var subtitleUtils = require('./utils/subtitles');
|
||||
|
||||
var HTMLVideo = function(containerElement) {
|
||||
var style = document.createElement('style');
|
||||
containerElement.appendChild(style);
|
||||
style.sheet.insertRule('#' + containerElement.id + ' video { width: 100%; height: 100%; }', style.sheet.cssRules.length);
|
||||
style.sheet.insertRule('#' + containerElement.id + ' video::cue { font-size: 22px; }', style.sheet.cssRules.length);
|
||||
if (!(containerElement instanceof HTMLElement)) {
|
||||
throw new Error('Instance of HTMLElement required as a first argument');
|
||||
}
|
||||
|
||||
var self = this;
|
||||
var events = new EventEmitter();
|
||||
var loaded = false;
|
||||
var destroyed = false;
|
||||
var dispatchArgsQueue = [];
|
||||
var subtitleCues = {};
|
||||
var subtitleTracks = [];
|
||||
var selectedSubtitleTrackId = null;
|
||||
var subtitleDelay = 0;
|
||||
var stylesElement = document.createElement('style');
|
||||
var videoElement = document.createElement('video');
|
||||
var subtitlesElement = document.createElement('div');
|
||||
|
||||
containerElement.appendChild(stylesElement);
|
||||
stylesElement.sheet.insertRule('#' + containerElement.id + ' video { width: 100%; height: 100%; position: relative; z-index: 0; }', stylesElement.sheet.cssRules.length);
|
||||
var subtitleStylesIndex = stylesElement.sheet.insertRule('#' + containerElement.id + ' .subtitles { position: absolute; right: 0; bottom: 0; left: 0; font-size: 26pt; color: white; text-align: center; }', stylesElement.sheet.cssRules.length);
|
||||
stylesElement.sheet.insertRule('#' + containerElement.id + ' .subtitles .cue { display: inline-block; padding: 0.2em; text-shadow: #222222 0px 0px 1.8px, #222222 0px 0px 1.8px, #222222 0px 0px 1.8px, #222222 0px 0px 1.8px, #222222 0px 0px 1.8px; }', stylesElement.sheet.cssRules.length);
|
||||
stylesElement.sheet.insertRule('#' + containerElement.id + ' .subtitles.dark-background .cue { text-shadow: none; background-color: #222222; }', stylesElement.sheet.cssRules.length);
|
||||
containerElement.appendChild(videoElement);
|
||||
videoElement.crossOrigin = 'anonymous';
|
||||
videoElement.autoplay = true;
|
||||
var events = new EventEmitter();
|
||||
var ready = false;
|
||||
var dispatchArgsQueue = [];
|
||||
var onEnded = function() {
|
||||
videoElement.controls = false;
|
||||
containerElement.appendChild(subtitlesElement);
|
||||
subtitlesElement.classList.add('subtitles');
|
||||
|
||||
function getPaused() {
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return !!videoElement.paused;
|
||||
}
|
||||
function getTime() {
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.floor(videoElement.currentTime * 1000);
|
||||
}
|
||||
function getDuration() {
|
||||
if (!loaded || isNaN(videoElement.duration)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.floor(videoElement.duration * 1000);
|
||||
}
|
||||
function getVolume() {
|
||||
if (destroyed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return videoElement.muted ? 0 : Math.floor(videoElement.volume * 100);
|
||||
}
|
||||
function getSubtitleTracks() {
|
||||
if (!loaded) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return subtitleTracks.slice();
|
||||
}
|
||||
function getSelectedSubtitleTrackId() {
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return selectedSubtitleTrackId;
|
||||
}
|
||||
function getSubtitleDelay() {
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return subtitleDelay;
|
||||
}
|
||||
function getSubtitleSize() {
|
||||
if (destroyed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseFloat(stylesElement.sheet.cssRules[subtitleStylesIndex].style.fontSize);
|
||||
}
|
||||
function getSubtitleDarkBackground() {
|
||||
if (destroyed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return subtitlesElement.classList.contains('dark-background');
|
||||
}
|
||||
function onEnded() {
|
||||
events.emit('ended');
|
||||
};
|
||||
var onError = function() {
|
||||
}
|
||||
function onError() {
|
||||
var message;
|
||||
var critical;
|
||||
switch (videoElement.error.code) {
|
||||
|
|
@ -45,164 +126,310 @@ var HTMLVideo = function(containerElement) {
|
|||
message: message,
|
||||
critical: critical
|
||||
});
|
||||
};
|
||||
var onPausedChanged = function() {
|
||||
events.emit('propChanged', 'paused', videoElement.paused);
|
||||
};
|
||||
var onTimeChanged = function() {
|
||||
events.emit('propChanged', 'time', videoElement.currentTime * 1000);
|
||||
};
|
||||
var onDurationChanged = function() {
|
||||
events.emit('propChanged', 'duration', isNaN(videoElement.duration) ? null : videoElement.duration * 1000);
|
||||
};
|
||||
var onVolumeChanged = function() {
|
||||
events.emit('propChanged', 'volume', !videoElement.muted ? videoElement.volume * 100 : 0);
|
||||
};
|
||||
var onSubtitlesChanged = function() {
|
||||
var subtitles = [];
|
||||
for (var i = 0; i < videoElement.textTracks.length; i++) {
|
||||
if (videoElement.textTracks[i].kind === 'subtitles') {
|
||||
subtitles.push({
|
||||
id: videoElement.textTracks[i].id,
|
||||
language: videoElement.textTracks[i].language,
|
||||
label: videoElement.textTracks[i].label
|
||||
});
|
||||
}
|
||||
|
||||
if (critical) {
|
||||
self.dispatch('command', 'stop');
|
||||
}
|
||||
}
|
||||
function onPausedChanged() {
|
||||
events.emit('propChanged', 'paused', getPaused());
|
||||
}
|
||||
function onTimeChanged() {
|
||||
events.emit('propChanged', 'time', getTime());
|
||||
}
|
||||
function onDurationChanged() {
|
||||
events.emit('propChanged', 'duration', getDuration());
|
||||
}
|
||||
function onVolumeChanged() {
|
||||
events.emit('propChanged', 'volume', getVolume());
|
||||
}
|
||||
function onSubtitleTracksChanged() {
|
||||
events.emit('propChanged', 'subtitleTracks', getSubtitleTracks());
|
||||
}
|
||||
function onSelectedSubtitleTrackIdChanged() {
|
||||
events.emit('propChanged', 'selectedSubtitleTrackId', getSelectedSubtitleTrackId());
|
||||
}
|
||||
function onSubtitleDelayChanged() {
|
||||
events.emit('propChanged', 'subtitleDelay', getSubtitleDelay());
|
||||
}
|
||||
function onSubtitleSizeChanged() {
|
||||
events.emit('propChanged', 'subtitleSize', getSubtitleSize());
|
||||
}
|
||||
function onSubtitleDarkBackgroundChanged() {
|
||||
events.emit('propChanged', 'subtitleDarkBackground', getSubtitleDarkBackground());
|
||||
}
|
||||
function updateSubtitleText() {
|
||||
while (subtitlesElement.hasChildNodes()) {
|
||||
subtitlesElement.removeChild(subtitlesElement.lastChild);
|
||||
}
|
||||
|
||||
events.emit('propChanged', 'subtitles', subtitles);
|
||||
};
|
||||
var onReady = function() {
|
||||
ready = true;
|
||||
if (!loaded || !Array.isArray(subtitleCues.times)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var time = getTime() + getSubtitleDelay();
|
||||
var cuesForTime = subtitleUtils.cuesForTime(subtitleCues, time);
|
||||
for (var i = 0; i < cuesForTime.length; i++) {
|
||||
var cueNode = subtitleUtils.render(cuesForTime[i]);
|
||||
cueNode.classList.add('cue');
|
||||
subtitlesElement.append(cueNode, document.createElement('br'));
|
||||
}
|
||||
}
|
||||
function flushArgsQueue() {
|
||||
for (var i = 0; i < dispatchArgsQueue.length; i++) {
|
||||
this.dispatch.apply(this, dispatchArgsQueue[i]);
|
||||
self.dispatch.apply(self, dispatchArgsQueue[i]);
|
||||
}
|
||||
|
||||
dispatchArgsQueue = [];
|
||||
}.bind(this);
|
||||
videoElement.addEventListener('ended', onEnded);
|
||||
videoElement.addEventListener('error', onError);
|
||||
}
|
||||
|
||||
this.on = function(eventName, listener) {
|
||||
if (destroyed) {
|
||||
throw new Error('Unable to add ' + eventName + ' listener to destroyed video');
|
||||
}
|
||||
|
||||
events.on(eventName, listener);
|
||||
};
|
||||
|
||||
this.dispatch = function() {
|
||||
if (arguments[0] === 'observeProp') {
|
||||
switch (arguments[1]) {
|
||||
case 'paused':
|
||||
events.emit('propValue', 'paused', videoElement.paused);
|
||||
videoElement.removeEventListener('pause', onPausedChanged);
|
||||
videoElement.removeEventListener('play', onPausedChanged);
|
||||
videoElement.addEventListener('pause', onPausedChanged);
|
||||
videoElement.addEventListener('play', onPausedChanged);
|
||||
return;
|
||||
case 'time':
|
||||
events.emit('propValue', 'time', videoElement.currentTime * 1000);
|
||||
videoElement.removeEventListener('timeupdate', onTimeChanged);
|
||||
videoElement.addEventListener('timeupdate', onTimeChanged);
|
||||
return;
|
||||
case 'duration':
|
||||
events.emit('propValue', 'duration', isNaN(videoElement.duration) ? null : videoElement.duration * 1000);
|
||||
videoElement.removeEventListener('durationchange', onDurationChanged);
|
||||
videoElement.addEventListener('durationchange', onDurationChanged);
|
||||
return;
|
||||
case 'volume':
|
||||
events.emit('propValue', 'volume', !videoElement.muted ? videoElement.volume * 100 : 0);
|
||||
videoElement.removeEventListener('volumechange', onVolumeChanged);
|
||||
videoElement.addEventListener('volumechange', onVolumeChanged);
|
||||
return;
|
||||
case 'subtitles':
|
||||
var subtitles = [];
|
||||
for (var i = 0; i < videoElement.textTracks.length; i++) {
|
||||
if (videoElement.textTracks[i].kind === 'subtitles') {
|
||||
subtitles.push({
|
||||
id: videoElement.textTracks[i].id,
|
||||
language: videoElement.textTracks[i].language,
|
||||
label: videoElement.textTracks[i].label
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
events.emit('propValue', 'subtitles', subtitles);
|
||||
videoElement.textTracks.removeEventListener('addtrack', onSubtitlesChanged);
|
||||
videoElement.textTracks.removeEventListener('removetrack', onSubtitlesChanged);
|
||||
videoElement.textTracks.addEventListener('addtrack', onSubtitlesChanged);
|
||||
videoElement.textTracks.addEventListener('removetrack', onSubtitlesChanged);
|
||||
return;
|
||||
default:
|
||||
throw new Error('observeProp not supported: ' + arguments[1]);
|
||||
}
|
||||
} else if (arguments[0] === 'setProp') {
|
||||
switch (arguments[1]) {
|
||||
case 'paused':
|
||||
arguments[2] ? videoElement.pause() : videoElement.play();
|
||||
return;
|
||||
case 'time':
|
||||
videoElement.currentTime = arguments[2] / 1000;
|
||||
return;
|
||||
case 'volume':
|
||||
videoElement.muted = false;
|
||||
videoElement.volume = arguments[2] / 100;
|
||||
return;
|
||||
default:
|
||||
throw new Error('setProp not supported: ' + arguments[1]);
|
||||
}
|
||||
} else if (arguments[0] === 'command') {
|
||||
switch (arguments[1]) {
|
||||
case 'load':
|
||||
if (!isNaN(arguments[3].time)) {
|
||||
videoElement.currentTime = arguments[3].time / 1000;
|
||||
}
|
||||
|
||||
videoElement.src = arguments[2].url;
|
||||
videoElement.load();
|
||||
onReady();
|
||||
return;
|
||||
case 'mute':
|
||||
videoElement.muted = true;
|
||||
break;
|
||||
case 'unmute':
|
||||
if (videoElement.volume === 0) {
|
||||
videoElement.volume = 0.5;
|
||||
}
|
||||
|
||||
videoElement.muted = false;
|
||||
break;
|
||||
case 'addExtraSubtitles':
|
||||
if (ready) {
|
||||
for (var i = 0; i < arguments[2].length; i++) {
|
||||
var track = document.createElement('track');
|
||||
track.kind = 'subtitles';
|
||||
track.id = arguments[2][i].id;
|
||||
track.src = arguments[2][i].url;
|
||||
track.label = arguments[2][i].label;
|
||||
track.srclang = arguments[2][i].language;
|
||||
videoElement.appendChild(track);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'stop':
|
||||
events.removeAllListeners();
|
||||
videoElement.removeEventListener('ended', onEnded);
|
||||
videoElement.removeEventListener('error', onError);
|
||||
videoElement.removeEventListener('pause', onPausedChanged);
|
||||
videoElement.removeEventListener('play', onPausedChanged);
|
||||
videoElement.removeEventListener('timeupdate', onTimeChanged);
|
||||
videoElement.removeEventListener('durationchange', onDurationChanged);
|
||||
videoElement.removeEventListener('volumechange', onVolumeChanged);
|
||||
videoElement.textTracks.removeEventListener('addtrack', onSubtitlesChanged);
|
||||
videoElement.textTracks.removeEventListener('removetrack', onSubtitlesChanged);
|
||||
videoElement.removeAttribute('src');
|
||||
videoElement.load();
|
||||
ready = false;
|
||||
return;
|
||||
default:
|
||||
throw new Error('command not supported: ' + arguments[1]);
|
||||
}
|
||||
if (destroyed) {
|
||||
throw new Error('Unable to dispatch ' + arguments[0] + ' to destroyed video');
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
switch (arguments[0]) {
|
||||
case 'observeProp':
|
||||
switch (arguments[1]) {
|
||||
case 'paused':
|
||||
events.emit('propValue', 'paused', getPaused());
|
||||
videoElement.removeEventListener('pause', onPausedChanged);
|
||||
videoElement.removeEventListener('play', onPausedChanged);
|
||||
videoElement.addEventListener('pause', onPausedChanged);
|
||||
videoElement.addEventListener('play', onPausedChanged);
|
||||
return;
|
||||
case 'time':
|
||||
events.emit('propValue', 'time', getTime());
|
||||
videoElement.removeEventListener('timeupdate', onTimeChanged);
|
||||
videoElement.addEventListener('timeupdate', onTimeChanged);
|
||||
return;
|
||||
case 'duration':
|
||||
events.emit('propValue', 'duration', getDuration());
|
||||
videoElement.removeEventListener('durationchange', onDurationChanged);
|
||||
videoElement.addEventListener('durationchange', onDurationChanged);
|
||||
return;
|
||||
case 'volume':
|
||||
events.emit('propValue', 'volume', getVolume());
|
||||
videoElement.removeEventListener('volumechange', onVolumeChanged);
|
||||
videoElement.addEventListener('volumechange', onVolumeChanged);
|
||||
return;
|
||||
case 'subtitleTracks':
|
||||
events.emit('propValue', 'subtitleTracks', getSubtitleTracks());
|
||||
return;
|
||||
case 'selectedSubtitleTrackId':
|
||||
events.emit('propValue', 'selectedSubtitleTrackId', getSelectedSubtitleTrackId());
|
||||
return;
|
||||
case 'subtitleSize':
|
||||
events.emit('propValue', 'subtitleSize', getSubtitleSize());
|
||||
return;
|
||||
case 'subtitleDelay':
|
||||
events.emit('propValue', 'subtitleDelay', getSubtitleDelay());
|
||||
return;
|
||||
case 'subtitleDarkBackground':
|
||||
events.emit('propValue', 'subtitleDarkBackground', getSubtitleDarkBackground());
|
||||
return;
|
||||
default:
|
||||
throw new Error('observeProp not supported: ' + arguments[1]);
|
||||
}
|
||||
case 'setProp':
|
||||
switch (arguments[1]) {
|
||||
case 'paused':
|
||||
if (loaded) {
|
||||
arguments[2] ? videoElement.pause() : videoElement.play();
|
||||
}
|
||||
break;
|
||||
case 'time':
|
||||
if (loaded) {
|
||||
if (!isNaN(arguments[2])) {
|
||||
videoElement.currentTime = arguments[2] / 1000;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'selectedSubtitleTrackId':
|
||||
if (loaded) {
|
||||
selectedSubtitleTrackId = null;
|
||||
subtitleDelay = 0;
|
||||
subtitleCues = {};
|
||||
for (var i = 0; i < subtitleTracks.length; i++) {
|
||||
var subtitleTrack = subtitleTracks[i];
|
||||
if (subtitleTrack.id === arguments[2]) {
|
||||
selectedSubtitleTrackId = subtitleTrack.id;
|
||||
fetch(subtitleTrack.url)
|
||||
.then(function(resp) {
|
||||
return resp.text();
|
||||
})
|
||||
.catch(function() {
|
||||
events.emit('error', {
|
||||
code: 70,
|
||||
message: 'Failed to fetch subtitles from ' + subtitleTrack.origin,
|
||||
critical: false
|
||||
});
|
||||
})
|
||||
.then(function(text) {
|
||||
if (selectedSubtitleTrackId === subtitleTrack.id) {
|
||||
subtitleCues = subtitleUtils.parse(text);
|
||||
updateSubtitleText();
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
events.emit('error', {
|
||||
code: 71,
|
||||
message: 'Failed to parse subtitles from ' + subtitleTrack.origin,
|
||||
critical: false
|
||||
});
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
onSubtitleDelayChanged();
|
||||
onSelectedSubtitleTrackIdChanged();
|
||||
updateSubtitleText();
|
||||
}
|
||||
break;
|
||||
case 'subtitleDelay':
|
||||
if (loaded) {
|
||||
if (!isNaN(arguments[2])) {
|
||||
subtitleDelay = parseFloat(arguments[2]);
|
||||
onSubtitleDelayChanged();
|
||||
updateSubtitleText();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'subtitleSize':
|
||||
if (!isNaN(arguments[2])) {
|
||||
stylesElement.sheet.cssRules[subtitleStylesIndex].style.fontSize = parseFloat(arguments[2]) + 'pt';
|
||||
onSubtitleSizeChanged();
|
||||
}
|
||||
return;
|
||||
case 'subtitleDarkBackground':
|
||||
if (arguments[2]) {
|
||||
subtitlesElement.classList.add('dark-background');
|
||||
} else {
|
||||
subtitlesElement.classList.remove('dark-background');
|
||||
}
|
||||
|
||||
onSubtitleDarkBackgroundChanged();
|
||||
return;
|
||||
case 'volume':
|
||||
if (!isNaN(arguments[2])) {
|
||||
videoElement.muted = false;
|
||||
videoElement.volume = arguments[2] / 100;
|
||||
}
|
||||
return;
|
||||
default:
|
||||
throw new Error('setProp not supported: ' + arguments[1]);
|
||||
}
|
||||
break;
|
||||
case 'command':
|
||||
switch (arguments[1]) {
|
||||
case 'addSubtitleTracks':
|
||||
if (loaded) {
|
||||
var extraSubtitleTracks = (Array.isArray(arguments[2]) ? arguments[2] : [])
|
||||
.filter(function(track) {
|
||||
return track &&
|
||||
typeof track.url === 'string' &&
|
||||
track.url.length > 0 &&
|
||||
typeof track.origin === 'string' &&
|
||||
track.origin.length > 0 &&
|
||||
track.origin !== 'EMBEDDED';
|
||||
})
|
||||
.map(function(track) {
|
||||
return Object.freeze(Object.assign({}, track, {
|
||||
id: track.url
|
||||
}));
|
||||
});
|
||||
subtitleTracks = subtitleTracks.concat(extraSubtitleTracks)
|
||||
.filter(function(track, index, tracks) {
|
||||
for (var i = 0; i < tracks.length; i++) {
|
||||
if (tracks[i].id === track.id) {
|
||||
return i === index;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
onSubtitleTracksChanged();
|
||||
}
|
||||
break;
|
||||
case 'mute':
|
||||
videoElement.muted = true;
|
||||
return;
|
||||
case 'unmute':
|
||||
videoElement.volume = videoElement.volume !== 0 ? videoElement.volume : 0.5;
|
||||
videoElement.muted = false;
|
||||
return;
|
||||
case 'stop':
|
||||
videoElement.removeEventListener('ended', onEnded);
|
||||
videoElement.removeEventListener('error', onError);
|
||||
videoElement.removeEventListener('timeupdate', updateSubtitleText);
|
||||
loaded = false;
|
||||
dispatchArgsQueue = [];
|
||||
subtitleCues = {};
|
||||
subtitleTracks = [];
|
||||
selectedSubtitleTrackId = null;
|
||||
subtitleDelay = 0;
|
||||
videoElement.removeAttribute('src');
|
||||
videoElement.load();
|
||||
videoElement.currentTime = 0;
|
||||
onPausedChanged();
|
||||
onTimeChanged();
|
||||
onDurationChanged();
|
||||
onSubtitleTracksChanged();
|
||||
onSelectedSubtitleTrackIdChanged();
|
||||
onSubtitleDelayChanged();
|
||||
updateSubtitleText();
|
||||
return;
|
||||
case 'load':
|
||||
var dispatchArgsQueueCopy = dispatchArgsQueue.slice();
|
||||
self.dispatch('command', 'stop');
|
||||
dispatchArgsQueue = dispatchArgsQueueCopy;
|
||||
videoElement.addEventListener('ended', onEnded);
|
||||
videoElement.addEventListener('error', onError);
|
||||
videoElement.addEventListener('timeupdate', updateSubtitleText);
|
||||
videoElement.autoplay = typeof arguments[3].autoplay === 'boolean' ? arguments[3].autoplay : true;
|
||||
videoElement.currentTime = !isNaN(arguments[3].time) ? arguments[3].time / 1000 : 0;
|
||||
videoElement.src = arguments[2].url;
|
||||
loaded = true;
|
||||
onPausedChanged();
|
||||
onTimeChanged();
|
||||
onDurationChanged();
|
||||
updateSubtitleText();
|
||||
flushArgsQueue();
|
||||
return;
|
||||
case 'destroy':
|
||||
self.dispatch('command', 'stop');
|
||||
destroyed = true;
|
||||
onVolumeChanged();
|
||||
onSubtitleSizeChanged();
|
||||
onSubtitleDarkBackgroundChanged();
|
||||
events.removeAllListeners();
|
||||
videoElement.removeEventListener('pause', onPausedChanged);
|
||||
videoElement.removeEventListener('play', onPausedChanged);
|
||||
videoElement.removeEventListener('timeupdate', onTimeChanged);
|
||||
videoElement.removeEventListener('durationchange', onDurationChanged);
|
||||
videoElement.removeEventListener('volumechange', onVolumeChanged);
|
||||
containerElement.removeChild(videoElement);
|
||||
containerElement.removeChild(stylesElement);
|
||||
containerElement.removeChild(subtitlesElement);
|
||||
return;
|
||||
default:
|
||||
throw new Error('command not supported: ' + arguments[1]);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid dispatch call: ' + Array.from(arguments).map(String));
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
dispatchArgsQueue.push(Array.from(arguments));
|
||||
}
|
||||
};
|
||||
|
|
@ -211,7 +438,7 @@ var HTMLVideo = function(containerElement) {
|
|||
HTMLVideo.manifest = {
|
||||
name: 'HTMLVideo',
|
||||
embedded: true,
|
||||
props: ['paused', 'time', 'duration', 'volume', 'subtitles']
|
||||
props: ['paused', 'time', 'duration', 'volume', 'subtitleTracks', 'selectedSubtitleTrackId', 'subtitleSize', 'subtitleDelay', 'subtitleDarkBackground']
|
||||
};
|
||||
|
||||
module.exports = HTMLVideo;
|
||||
|
|
|
|||
85
src/routes/Player/Video/stremio-video/utils/subtitles.js
Normal file
85
src/routes/Player/Video/stremio-video/utils/subtitles.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
var VTTJS = require('vtt.js');
|
||||
|
||||
function binarySearchUpperBound(array, value) {
|
||||
if (value < array[0] || array[array.length - 1] < value) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
var left = 0;
|
||||
var right = array.length - 1;
|
||||
var index = -1;
|
||||
while (left <= right) {
|
||||
var middle = Math.floor((left + right) / 2);
|
||||
if (array[middle] > value) {
|
||||
right = middle - 1;
|
||||
} else if (array[middle] < value) {
|
||||
left = middle + 1;
|
||||
} else {
|
||||
index = middle;
|
||||
left = middle + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return index !== -1 ? index : right;
|
||||
}
|
||||
|
||||
function parse(text) {
|
||||
var nativeVTTCue = VTTCue;
|
||||
global.VTTCue = VTTJS.VTTCue;
|
||||
var cues = [];
|
||||
var cuesForTime = {};
|
||||
var parser = new VTTJS.WebVTT.Parser(window, VTTJS.WebVTT.StringDecoder());
|
||||
parser.oncue = function(c) {
|
||||
var cue = {
|
||||
startTime: (c.startTime * 1000) | 0,
|
||||
endTime: (c.endTime * 1000) | 0,
|
||||
text: c.text
|
||||
};
|
||||
cues.push(cue);
|
||||
cuesForTime[cue.startTime] = cuesForTime[cue.startTime] || [];
|
||||
cuesForTime[cue.endTime] = cuesForTime[cue.endTime] || [];
|
||||
};
|
||||
parser.parse(text);
|
||||
parser.flush();
|
||||
cuesForTime.times = Object.keys(cuesForTime)
|
||||
.map(function(time) {
|
||||
return parseInt(time);
|
||||
})
|
||||
.sort(function(t1, t2) {
|
||||
return t1 - t2;
|
||||
});
|
||||
for (var i = 0; i < cues.length; i++) {
|
||||
cuesForTime[cues[i].startTime].push(cues[i]);
|
||||
var startTimeIndex = binarySearchUpperBound(cuesForTime.times, cues[i].startTime);
|
||||
for (var j = startTimeIndex + 1; j < cuesForTime.times.length; j++) {
|
||||
if (cues[i].endTime <= cuesForTime.times[j]) {
|
||||
break;
|
||||
}
|
||||
|
||||
cuesForTime[cuesForTime.times[j]].push(cues[i]);
|
||||
}
|
||||
}
|
||||
for (var i = 0; i < cuesForTime.times.length; i++) {
|
||||
cuesForTime[cuesForTime.times[i]].sort(function(c1, c2) {
|
||||
return c1.startTime - c2.startTime ||
|
||||
c1.endTime - c2.endTime;
|
||||
});
|
||||
}
|
||||
global.VTTCue = nativeVTTCue;
|
||||
return cuesForTime;
|
||||
}
|
||||
|
||||
function cuesForTime(cues, time) {
|
||||
var index = binarySearchUpperBound(cues.times, time);
|
||||
return index !== -1 ? cues[cues.times[index]] : [];
|
||||
}
|
||||
|
||||
function render(cue) {
|
||||
return VTTJS.WebVTT.convertCueToDOMTree(window, cue.text);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parse,
|
||||
cuesForTime,
|
||||
render
|
||||
};
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
.player-container, :global(.player-popup-container) {
|
||||
--control-bar-button-size: 60px;
|
||||
}
|
||||
|
||||
.player-container {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
|
@ -11,5 +16,13 @@
|
|||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 0;
|
||||
|
||||
&.control-bar-layer {
|
||||
top: initial;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.subtitles) {
|
||||
bottom: calc(var(--control-bar-button-size) * 3) !important;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue