Merge branch 'master' of github.com:Stremio/stremio-web into addons-screen

This commit is contained in:
svetlagasheva 2019-01-14 14:53:49 +02:00
commit cd44e3d71d
33 changed files with 2917 additions and 1224 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -35,4 +35,9 @@
right: 0;
bottom: 0;
}
&-hidden {
display: none;
border: 1px solid;
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,3 @@
import PlayPauseButton from './PlayPauseButton';
export default PlayPauseButton;

View file

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

View file

@ -0,0 +1,3 @@
import SeekBar from './SeekBar';
export default SeekBar;

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

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

View file

@ -0,0 +1,3 @@
import SubtitlesPicker from './SubtitlesPicker';
export default SubtitlesPicker;

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

View file

@ -1,3 +0,0 @@
import TimeSlider from './TimeSlider';
export default TimeSlider;

View file

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

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

View file

@ -0,0 +1,3 @@
import VolumeBar from './VolumeBar';
export default VolumeBar;

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

View file

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

View file

@ -1,3 +0,0 @@
import VolumeSlider from './VolumeSlider';
export default VolumeSlider;

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

2135
yarn.lock

File diff suppressed because it is too large Load diff