diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2020-09-28 13:29:43 +0200 |
---|---|---|
committer | Thibaut Girka <thib@sitedethib.com> | 2020-10-26 19:51:09 +0100 |
commit | 8f950e540b83e13748c0df9bc30afbb06ef26f3e (patch) | |
tree | 1fb7682800a866383b904083b78df47685d7672f /app/javascript/flavours | |
parent | 9c88792f0a60f2b383f0d3919475403e1e244faf (diff) |
[Glitch] Add pop-out player for audio/video in web UI
port d88a79b4566869ede24958fbff946e357bbb3cb9 to glitch-soc Signed-off-by: Thibaut Girka <thib@sitedethib.com>
Diffstat (limited to 'app/javascript/flavours')
21 files changed, 681 insertions, 56 deletions
diff --git a/app/javascript/flavours/glitch/actions/picture_in_picture.js b/app/javascript/flavours/glitch/actions/picture_in_picture.js new file mode 100644 index 000000000..4085cb59e --- /dev/null +++ b/app/javascript/flavours/glitch/actions/picture_in_picture.js @@ -0,0 +1,38 @@ +// @ts-check + +export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY'; +export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE'; + +/** + * @typedef MediaProps + * @property {string} src + * @property {boolean} muted + * @property {number} volume + * @property {number} currentTime + * @property {string} poster + * @property {string} backgroundColor + * @property {string} foregroundColor + * @property {string} accentColor + */ + +/** + * @param {string} statusId + * @param {string} accountId + * @param {string} playerType + * @param {MediaProps} props + * @return {object} + */ +export const deployPictureInPicture = (statusId, accountId, playerType, props) => ({ + type: PICTURE_IN_PICTURE_DEPLOY, + statusId, + accountId, + playerType, + props, +}); + +/* + * @return {object} + */ +export const removePictureInPicture = () => ({ + type: PICTURE_IN_PICTURE_REMOVE, +}); diff --git a/app/javascript/flavours/glitch/components/animated_number.js b/app/javascript/flavours/glitch/components/animated_number.js index e3235e368..3cc5173dd 100644 --- a/app/javascript/flavours/glitch/components/animated_number.js +++ b/app/javascript/flavours/glitch/components/animated_number.js @@ -5,10 +5,21 @@ import TransitionMotion from 'react-motion/lib/TransitionMotion'; import spring from 'react-motion/lib/spring'; import { reduceMotion } from 'flavours/glitch/util/initial_state'; +const obfuscatedCount = count => { + if (count < 0) { + return 0; + } else if (count <= 1) { + return count; + } else { + return '1+'; + } +}; + export default class AnimatedNumber extends React.PureComponent { static propTypes = { value: PropTypes.number.isRequired, + obfuscate: PropTypes.bool, }; state = { @@ -36,11 +47,11 @@ export default class AnimatedNumber extends React.PureComponent { } render () { - const { value } = this.props; + const { value, obfuscate } = this.props; const { direction } = this.state; if (reduceMotion) { - return <FormattedNumber value={value} />; + return obfuscate ? obfuscatedCount(value) : <FormattedNumber value={value} />; } const styles = [{ @@ -54,7 +65,7 @@ export default class AnimatedNumber extends React.PureComponent { {items => ( <span className='animated-number'> {items.map(({ key, data, style }) => ( - <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}><FormattedNumber value={data} /></span> + <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span> ))} </span> )} diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js index e134d0a39..51540d17d 100644 --- a/app/javascript/flavours/glitch/components/icon_button.js +++ b/app/javascript/flavours/glitch/components/icon_button.js @@ -4,6 +4,7 @@ import spring from 'react-motion/lib/spring'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import Icon from 'flavours/glitch/components/icon'; +import AnimatedNumber from 'flavours/glitch/components/animated_number'; export default class IconButton extends React.PureComponent { @@ -27,6 +28,8 @@ export default class IconButton extends React.PureComponent { overlay: PropTypes.bool, tabIndex: PropTypes.string, label: PropTypes.string, + counter: PropTypes.number, + obfuscateCount: PropTypes.bool, }; static defaultProps = { @@ -104,6 +107,8 @@ export default class IconButton extends React.PureComponent { pressed, tabIndex, title, + counter, + obfuscateCount, } = this.props; const { @@ -120,6 +125,10 @@ export default class IconButton extends React.PureComponent { overlayed: overlay, }); + if (typeof counter !== 'undefined') { + style.width = 'auto'; + } + return ( <button aria-label={title} @@ -135,7 +144,7 @@ export default class IconButton extends React.PureComponent { tabIndex={tabIndex} disabled={disabled} > - <Icon id={icon} fixedWidth aria-hidden='true' /> + <Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>} {this.props.label} </button> ); diff --git a/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js new file mode 100644 index 000000000..01dce0a38 --- /dev/null +++ b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Icon from 'flavours/glitch/components/icon'; +import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture'; +import { connect } from 'react-redux'; +import { debounce } from 'lodash'; +import { FormattedMessage } from 'react-intl'; + +export default @connect() +class PictureInPicturePlaceholder extends React.PureComponent { + + static propTypes = { + width: PropTypes.number, + dispatch: PropTypes.func.isRequired, + }; + + state = { + width: this.props.width, + height: this.props.width && (this.props.width / (16/9)), + }; + + handleClick = () => { + const { dispatch } = this.props; + dispatch(removePictureInPicture()); + } + + setRef = c => { + this.node = c; + + if (this.node) { + this._setDimensions(); + } + } + + _setDimensions () { + const width = this.node.offsetWidth; + const height = width / (16/9); + + this.setState({ width, height }); + } + + componentDidMount () { + window.addEventListener('resize', this.handleResize, { passive: true }); + } + + componentWillUnmount () { + window.removeEventListener('resize', this.handleResize); + } + + handleResize = debounce(() => { + if (this.node) { + this._setDimensions(); + } + }, 250, { + trailing: true, + }); + + render () { + const { height } = this.state; + + return ( + <div ref={this.setRef} className='picture-in-picture-placeholder' style={{ height }} role='button' tabIndex='0' onClick={this.handleClick}> + <Icon id='window-restore' /> + <FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index fc7940e5a..1b7dce4c4 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -17,6 +17,7 @@ import classNames from 'classnames'; import { autoUnfoldCW } from 'flavours/glitch/util/content_warning'; import PollContainer from 'flavours/glitch/containers/poll_container'; import { displayMedia } from 'flavours/glitch/util/initial_state'; +import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; // We use the component (and not the container) since we do not want // to use the progress bar to show download progress @@ -97,6 +98,8 @@ class Status extends ImmutablePureComponent { cachedMediaWidth: PropTypes.number, onClick: PropTypes.func, scrollKey: PropTypes.string, + deployPictureInPicture: PropTypes.func, + usingPiP: PropTypes.bool, }; state = { @@ -123,6 +126,7 @@ class Status extends ImmutablePureComponent { 'hidden', 'expanded', 'unread', + 'usingPiP', ] updateOnStates = [ @@ -394,6 +398,12 @@ class Status extends ImmutablePureComponent { } } + handleDeployPictureInPicture = (type, mediaProps) => { + const { deployPictureInPicture, status } = this.props; + + deployPictureInPicture(status, type, mediaProps); + } + handleHotkeyReply = e => { e.preventDefault(); this.props.onReply(this.props.status, this.context.router.history); @@ -496,6 +506,7 @@ class Status extends ImmutablePureComponent { hidden, unread, featured, + usingPiP, ...other } = this.props; const { isExpanded, isCollapsed, forceFilter } = this.state; @@ -576,6 +587,9 @@ class Status extends ImmutablePureComponent { if (status.get('poll')) { media = <PollContainer pollId={status.get('poll')} />; mediaIcon = 'tasks'; + } else if (usingPiP) { + media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />; + mediaIcon = 'video-camera'; } else if (attachments.size > 0) { if (muted || attachments.some(item => item.get('type') === 'unknown')) { media = ( @@ -601,6 +615,7 @@ class Status extends ImmutablePureComponent { width={this.props.cachedMediaWidth} height={110} cacheWidth={this.props.cacheMediaWidth} + deployPictureInPicture={this.handleDeployPictureInPicture} /> )} </Bundle> @@ -624,6 +639,7 @@ class Status extends ImmutablePureComponent { onOpenVideo={this.handleOpenVideo} width={this.props.cachedMediaWidth} cacheWidth={this.props.cacheMediaWidth} + deployPictureInPicture={this.handleDeployPictureInPicture} visible={this.state.showMedia} onToggleVisibility={this.handleToggleMediaVisibility} />)} diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js index cfb03c21b..2ccb02c62 100644 --- a/app/javascript/flavours/glitch/components/status_action_bar.js +++ b/app/javascript/flavours/glitch/components/status_action_bar.js @@ -40,16 +40,6 @@ const messages = defineMessages({ hide: { id: 'status.hide', defaultMessage: 'Hide toot' }, }); -const obfuscatedCount = count => { - if (count < 0) { - return 0; - } else if (count <= 1) { - return count; - } else { - return '1+'; - } -}; - export default @injectIntl class StatusActionBar extends ImmutablePureComponent { @@ -284,10 +274,14 @@ class StatusActionBar extends ImmutablePureComponent { ); if (showReplyCount) { replyButton = ( - <div className='status__action-bar__counter'> - {replyButton} - <span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span> - </div> + <IconButton + className='status__action-bar-button' + title={replyTitle} + icon={replyIcon} + onClick={this.handleReplyClick} + counter={status.get('replies_count')} + obfuscateCount + /> ); } diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js index 2cbe3d094..48b0d8255 100644 --- a/app/javascript/flavours/glitch/containers/status_container.js +++ b/app/javascript/flavours/glitch/containers/status_container.js @@ -22,6 +22,7 @@ import { initMuteModal } from 'flavours/glitch/actions/mutes'; import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initReport } from 'flavours/glitch/actions/reports'; import { openModal } from 'flavours/glitch/actions/modal'; +import { deployPictureInPicture } from 'flavours/glitch/actions/picture_in_picture'; import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state'; @@ -69,6 +70,7 @@ const makeMapStateToProps = () => { account : account || props.account, settings : state.get('local_settings'), prepend : prepend || props.prepend, + usingPiP : state.get('picture_in_picture').statusId === props.id, }; }; @@ -245,6 +247,10 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({ } }, + deployPictureInPicture (status, type, mediaProps) { + dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps)); + }, + }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); diff --git a/app/javascript/flavours/glitch/features/audio/index.js b/app/javascript/flavours/glitch/features/audio/index.js index 7a2fb7fb6..6d09ac8d2 100644 --- a/app/javascript/flavours/glitch/features/audio/index.js +++ b/app/javascript/flavours/glitch/features/audio/index.js @@ -37,7 +37,11 @@ class Audio extends React.PureComponent { backgroundColor: PropTypes.string, foregroundColor: PropTypes.string, accentColor: PropTypes.string, + currentTime: PropTypes.number, autoPlay: PropTypes.bool, + volume: PropTypes.number, + muted: PropTypes.bool, + deployPictureInPicture: PropTypes.func, }; state = { @@ -64,6 +68,19 @@ class Audio extends React.PureComponent { } } + _pack() { + return { + src: this.props.src, + volume: this.audio.volume, + muted: this.audio.muted, + currentTime: this.audio.currentTime, + poster: this.props.poster, + backgroundColor: this.props.backgroundColor, + foregroundColor: this.props.foregroundColor, + accentColor: this.props.accentColor, + }; + } + _setDimensions () { const width = this.player.offsetWidth; const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9)); @@ -100,6 +117,7 @@ class Audio extends React.PureComponent { } componentDidMount () { + window.addEventListener('scroll', this.handleScroll); window.addEventListener('resize', this.handleResize, { passive: true }); } @@ -115,7 +133,12 @@ class Audio extends React.PureComponent { } componentWillUnmount () { + window.removeEventListener('scroll', this.handleScroll); window.removeEventListener('resize', this.handleResize); + + if (!this.state.paused && this.audio && this.props.deployPictureInPicture) { + this.props.deployPictureInPicture('audio', this._pack()); + } } togglePlay = () => { @@ -243,6 +266,25 @@ class Audio extends React.PureComponent { } }, 15); + handleScroll = throttle(() => { + if (!this.canvas || !this.audio) { + return; + } + + const { top, height } = this.canvas.getBoundingClientRect(); + const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); + + if (!this.state.paused && !inView) { + this.audio.pause(); + + if (this.props.deployPictureInPicture) { + this.props.deployPictureInPicture('audio', this._pack()); + } + + this.setState({ paused: true }); + } + }, 150, { trailing: true }); + handleMouseEnter = () => { this.setState({ hovered: true }); } @@ -252,10 +294,22 @@ class Audio extends React.PureComponent { } handleLoadedData = () => { - const { autoPlay } = this.props; + const { autoPlay, currentTime, volume, muted } = this.props; + + if (currentTime) { + this.audio.currentTime = currentTime; + } + + if (volume !== undefined) { + this.audio.volume = volume; + } + + if (muted !== undefined) { + this.audio.muted = muted; + } if (autoPlay) { - this.audio.play(); + this.togglePlay(); } } @@ -341,7 +395,7 @@ class Audio extends React.PureComponent { render () { const { src, intl, alt, editable, autoPlay } = this.props; const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state; - const progress = (currentTime / duration) * 100; + const progress = Math.min((currentTime / duration) * 100, 100); return ( <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js new file mode 100644 index 000000000..75bd39dea --- /dev/null +++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js @@ -0,0 +1,137 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from 'flavours/glitch/components/icon_button'; +import classNames from 'classnames'; +import { me, boostModal } from 'flavours/glitch/util/initial_state'; +import { defineMessages, injectIntl } from 'react-intl'; +import { replyCompose } from 'flavours/glitch/actions/compose'; +import { reblog, favourite, unreblog, unfavourite } from 'flavours/glitch/actions/interactions'; +import { makeGetStatus } from 'flavours/glitch/selectors'; +import { openModal } from 'flavours/glitch/actions/modal'; + +const messages = defineMessages({ + reply: { id: 'status.reply', defaultMessage: 'Reply' }, + replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, + reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, + reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, + cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, + cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, + favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, +}); + +const makeMapStateToProps = () => { + const getStatus = makeGetStatus(); + + const mapStateToProps = (state, { statusId }) => ({ + status: getStatus(state, { id: statusId }), + askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, + }); + + return mapStateToProps; +}; + +export default @connect(makeMapStateToProps) +@injectIntl +class Footer extends ImmutablePureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + statusId: PropTypes.string.isRequired, + status: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + askReplyConfirmation: PropTypes.bool, + }; + + _performReply = () => { + const { dispatch, status } = this.props; + dispatch(replyCompose(status, this.context.router.history)); + }; + + handleReplyClick = () => { + const { dispatch, askReplyConfirmation, intl } = this.props; + + if (askReplyConfirmation) { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: this._performReply, + })); + } else { + this._performReply(); + } + }; + + handleFavouriteClick = () => { + const { dispatch, status } = this.props; + + if (status.get('favourited')) { + dispatch(unfavourite(status)); + } else { + dispatch(favourite(status)); + } + }; + + _performReblog = () => { + const { dispatch, status } = this.props; + dispatch(reblog(status)); + } + + handleReblogClick = e => { + const { dispatch, status } = this.props; + + if (status.get('reblogged')) { + dispatch(unreblog(status)); + } else if ((e && e.shiftKey) || !boostModal) { + this._performReblog(); + } else { + dispatch(openModal('BOOST', { status, onReblog: this._performReblog })); + } + }; + + render () { + const { status, intl } = this.props; + + const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); + const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private'; + + let replyIcon, replyTitle; + + if (status.get('in_reply_to_id', null) === null) { + replyIcon = 'reply'; + replyTitle = intl.formatMessage(messages.reply); + } else { + replyIcon = 'reply-all'; + replyTitle = intl.formatMessage(messages.replyAll); + } + + let reblogTitle = ''; + + if (status.get('reblogged')) { + reblogTitle = intl.formatMessage(messages.cancel_reblog_private); + } else if (publicStatus) { + reblogTitle = intl.formatMessage(messages.reblog); + } else if (reblogPrivate) { + reblogTitle = intl.formatMessage(messages.reblog_private); + } else { + reblogTitle = intl.formatMessage(messages.cannot_reblog); + } + + return ( + <div className='picture-in-picture__footer'> + <IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount /> + <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} /> + <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js new file mode 100644 index 000000000..24adcde25 --- /dev/null +++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js @@ -0,0 +1,40 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PropTypes from 'prop-types'; +import IconButton from 'flavours/glitch/components/icon_button'; +import { Link } from 'react-router-dom'; +import Avatar from 'flavours/glitch/components/avatar'; +import DisplayName from 'flavours/glitch/components/display_name'; + +const mapStateToProps = (state, { accountId }) => ({ + account: state.getIn(['accounts', accountId]), +}); + +export default @connect(mapStateToProps) +class Header extends ImmutablePureComponent { + + static propTypes = { + accountId: PropTypes.string.isRequired, + statusId: PropTypes.string.isRequired, + account: ImmutablePropTypes.map.isRequired, + onClose: PropTypes.func.isRequired, + }; + + render () { + const { account, statusId, onClose } = this.props; + + return ( + <div className='picture-in-picture__header'> + <Link to={`/statuses/${statusId}`} className='picture-in-picture__header__account'> + <Avatar account={account} size={36} /> + <DisplayName account={account} /> + </Link> + + <IconButton icon='times' onClick={onClose} title='Close' /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/index.js b/app/javascript/flavours/glitch/features/picture_in_picture/index.js new file mode 100644 index 000000000..200f2fc7f --- /dev/null +++ b/app/javascript/flavours/glitch/features/picture_in_picture/index.js @@ -0,0 +1,85 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import Video from 'flavours/glitch/features/video'; +import Audio from 'flavours/glitch/features/audio'; +import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture'; +import Header from './components/header'; +import Footer from './components/footer'; + +const mapStateToProps = state => ({ + ...state.get('picture_in_picture'), +}); + +export default @connect(mapStateToProps) +class PictureInPicture extends React.Component { + + static propTypes = { + statusId: PropTypes.string, + accountId: PropTypes.string, + type: PropTypes.string, + src: PropTypes.string, + muted: PropTypes.bool, + volume: PropTypes.number, + currentTime: PropTypes.number, + poster: PropTypes.string, + backgroundColor: PropTypes.string, + foregroundColor: PropTypes.string, + accentColor: PropTypes.string, + dispatch: PropTypes.func.isRequired, + }; + + handleClose = () => { + const { dispatch } = this.props; + dispatch(removePictureInPicture()); + } + + render () { + const { type, src, currentTime, accountId, statusId } = this.props; + + if (!currentTime) { + return null; + } + + let player; + + if (type === 'video') { + player = ( + <Video + src={src} + currentTime={this.props.currentTime} + volume={this.props.volume} + muted={this.props.muted} + autoPlay + inline + alwaysVisible + /> + ); + } else if (type === 'audio') { + player = ( + <Audio + src={src} + currentTime={this.props.currentTime} + volume={this.props.volume} + muted={this.props.muted} + poster={this.props.poster} + backgroundColor={this.props.backgroundColor} + foregroundColor={this.props.foregroundColor} + accentColor={this.props.accentColor} + autoPlay + /> + ); + } + + return ( + <div className='picture-in-picture'> + <Header accountId={accountId} statusId={statusId} onClose={this.handleClose} /> + + {player} + + <Footer statusId={statusId} /> + </div> + ); + } + +} diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index e4aecbf94..04d350bcb 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -18,6 +18,7 @@ import classNames from 'classnames'; import PollContainer from 'flavours/glitch/containers/poll_container'; import Icon from 'flavours/glitch/components/icon'; import AnimatedNumber from 'flavours/glitch/components/animated_number'; +import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; export default class DetailedStatus extends ImmutablePureComponent { @@ -37,6 +38,7 @@ export default class DetailedStatus extends ImmutablePureComponent { domain: PropTypes.string.isRequired, compact: PropTypes.bool, showMedia: PropTypes.bool, + usingPiP: PropTypes.bool, onToggleMediaVisibility: PropTypes.func, }; @@ -109,7 +111,7 @@ export default class DetailedStatus extends ImmutablePureComponent { render () { const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; - const { expanded, onToggleHidden, settings } = this.props; + const { expanded, onToggleHidden, settings, usingPiP } = this.props; const outerStyle = { boxSizing: 'border-box' }; const { compact } = this.props; @@ -131,6 +133,9 @@ export default class DetailedStatus extends ImmutablePureComponent { if (status.get('poll')) { media = <PollContainer pollId={status.get('poll')} />; mediaIcon = 'tasks'; + } else if (usingPiP) { + media = <PictureInPicturePlaceholder />; + mediaIcon = 'video-camera'; } else if (status.get('media_attachments').size > 0) { if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { media = <AttachmentList media={status.get('media_attachments')} />; diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js index 3e2e95f35..b330adf3f 100644 --- a/app/javascript/flavours/glitch/features/status/index.js +++ b/app/javascript/flavours/glitch/features/status/index.js @@ -132,6 +132,7 @@ const makeMapStateToProps = () => { settings: state.get('local_settings'), askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0, domain: state.getIn(['meta', 'domain']), + usingPiP: state.get('picture_in_picture').statusId === props.params.statusId, }; }; @@ -157,6 +158,7 @@ class Status extends ImmutablePureComponent { askReplyConfirmation: PropTypes.bool, multiColumn: PropTypes.bool, domain: PropTypes.string.isRequired, + usingPiP: PropTypes.bool, }; state = { @@ -514,7 +516,7 @@ class Status extends ImmutablePureComponent { render () { let ancestors, descendants; const { setExpansion } = this; - const { status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn } = this.props; + const { status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, usingPiP } = this.props; const { fullscreen, isExpanded } = this.state; if (status === null) { @@ -578,6 +580,7 @@ class Status extends ImmutablePureComponent { domain={domain} showMedia={this.state.showMedia} onToggleMediaVisibility={this.handleToggleMediaVisibility} + usingPiP={usingPiP} /> <ActionBar diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js index 23e8dac7e..aa6554107 100644 --- a/app/javascript/flavours/glitch/features/ui/components/media_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js @@ -140,7 +140,7 @@ class MediaModal extends ImmutablePureComponent { src={image.get('url')} width={image.get('width')} height={image.get('height')} - startTime={time || 0} + currentTime={time || 0} onCloseVideo={onClose} detailed alt={image.get('description')} diff --git a/app/javascript/flavours/glitch/features/ui/components/video_modal.js b/app/javascript/flavours/glitch/features/ui/components/video_modal.js index afeff90a4..c8d2a81b0 100644 --- a/app/javascript/flavours/glitch/features/ui/components/video_modal.js +++ b/app/javascript/flavours/glitch/features/ui/components/video_modal.js @@ -42,9 +42,9 @@ export default class VideoModal extends ImmutablePureComponent { preview={media.get('preview_url')} blurhash={media.get('blurhash')} src={media.get('url')} - startTime={options.startTime} + currentTime={options.startTime} autoPlay={options.autoPlay} - defaultVolume={options.defaultVolume} + volume={options.defaultVolume} onCloseVideo={onClose} detailed alt={media.get('description')} diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js index 1294a8a16..61a34fd2b 100644 --- a/app/javascript/flavours/glitch/features/ui/index.js +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -19,6 +19,7 @@ import PermaLink from 'flavours/glitch/components/permalink'; import ColumnsAreaContainer from './containers/columns_area_container'; import classNames from 'classnames'; import Favico from 'favico.js'; +import PictureInPicture from 'flavours/glitch/features/picture_in_picture'; import { Compose, Status, @@ -614,6 +615,7 @@ class UI extends React.Component { {children} </SwitchingColumnsArea> + <PictureInPicture /> <NotificationsContainer /> <LoadingBarContainer className='loading-bar' /> <ModalContainer /> diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js index cc60a0d2e..95bee1331 100644 --- a/app/javascript/flavours/glitch/features/video/index.js +++ b/app/javascript/flavours/glitch/features/video/index.js @@ -103,7 +103,7 @@ class Video extends React.PureComponent { width: PropTypes.number, height: PropTypes.number, sensitive: PropTypes.bool, - startTime: PropTypes.number, + currentTime: PropTypes.number, onOpenVideo: PropTypes.func, onCloseVideo: PropTypes.func, letterbox: PropTypes.bool, @@ -111,15 +111,18 @@ class Video extends React.PureComponent { detailed: PropTypes.bool, inline: PropTypes.bool, editable: PropTypes.bool, + alwaysVisible: PropTypes.bool, cacheWidth: PropTypes.func, intl: PropTypes.object.isRequired, visible: PropTypes.bool, onToggleVisibility: PropTypes.func, + deployPictureInPicture: PropTypes.func, preventPlayback: PropTypes.bool, blurhash: PropTypes.string, link: PropTypes.node, autoPlay: PropTypes.bool, - defaultVolume: PropTypes.number, + volume: PropTypes.number, + muted: PropTypes.bool, }; state = { @@ -298,16 +301,27 @@ class Video extends React.PureComponent { document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true); document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true); + window.addEventListener('scroll', this.handleScroll); window.addEventListener('resize', this.handleResize, { passive: true }); } componentWillUnmount () { + window.removeEventListener('scroll', this.handleScroll); window.removeEventListener('resize', this.handleResize); document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true); document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true); document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true); + + if (!this.state.paused && this.video && this.props.deployPictureInPicture) { + this.props.deployPictureInPicture('video', { + src: this.props.src, + currentTime: this.video.currentTime, + muted: this.video.muted, + volume: this.video.volume, + }); + } } componentDidUpdate (prevProps) { @@ -330,6 +344,30 @@ class Video extends React.PureComponent { trailing: true, }); + handleScroll = throttle(() => { + if (!this.video) { + return; + } + + const { top, height } = this.video.getBoundingClientRect(); + const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); + + if (!this.state.paused && !inView) { + this.video.pause(); + + if (this.props.deployPictureInPicture) { + this.props.deployPictureInPicture('video', { + src: this.props.src, + currentTime: this.video.currentTime, + muted: this.video.muted, + volume: this.video.volume, + }); + } + + this.setState({ paused: true }); + } + }, 150, { trailing: true }) + handleFullscreenChange = () => { this.setState({ fullscreen: isFullscreen() }); } @@ -360,15 +398,21 @@ class Video extends React.PureComponent { } handleLoadedData = () => { - if (this.props.startTime) { - this.video.currentTime = this.props.startTime; + const { currentTime, volume, muted, autoPlay } = this.props; + + if (currentTime) { + this.video.currentTime = currentTime; + } + + if (volume !== undefined) { + this.video.volume = volume; } - if (this.props.defaultVolume !== undefined) { - this.video.volume = this.props.defaultVolume; + if (muted !== undefined) { + this.video.muted = muted; } - if (this.props.autoPlay) { + if (autoPlay) { this.video.play(); } } @@ -413,9 +457,9 @@ class Video extends React.PureComponent { } render () { - const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link, editable, blurhash } = this.props; + const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link, editable, blurhash } = this.props; const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; - const progress = (currentTime / duration) * 100; + const progress = Math.min((currentTime / duration) * 100, 100); const playerStyle = {}; const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth }); @@ -440,7 +484,7 @@ class Video extends React.PureComponent { let preload; - if (startTime || fullscreen || dragging) { + if (this.props.currentTime || fullscreen || dragging) { preload = 'auto'; } else if (detailed) { preload = 'metadata'; @@ -532,7 +576,7 @@ class Video extends React.PureComponent { </div> <div className='video-player__buttons right'> - {(!onCloseVideo && !editable && !fullscreen) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>} + {(!onCloseVideo && !editable && !fullscreen && !this.props.alwaysVisible) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>} {(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>} {onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>} <button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button> diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js index cadbd01a3..b1ddb769e 100644 --- a/app/javascript/flavours/glitch/reducers/index.js +++ b/app/javascript/flavours/glitch/reducers/index.js @@ -38,6 +38,7 @@ import trends from './trends'; import announcements from './announcements'; import markers from './markers'; import account_notes from './account_notes'; +import picture_in_picture from './picture_in_picture'; const reducers = { announcements, @@ -79,6 +80,7 @@ const reducers = { trends, markers, account_notes, + picture_in_picture, }; export default combineReducers(reducers); diff --git a/app/javascript/flavours/glitch/reducers/picture_in_picture.js b/app/javascript/flavours/glitch/reducers/picture_in_picture.js new file mode 100644 index 000000000..f552a59c2 --- /dev/null +++ b/app/javascript/flavours/glitch/reducers/picture_in_picture.js @@ -0,0 +1,22 @@ +import { PICTURE_IN_PICTURE_DEPLOY, PICTURE_IN_PICTURE_REMOVE } from 'flavours/glitch/actions/picture_in_picture'; + +const initialState = { + statusId: null, + accountId: null, + type: null, + src: null, + muted: false, + volume: 0, + currentTime: 0, +}; + +export default function pictureInPicture(state = initialState, action) { + switch(action.type) { + case PICTURE_IN_PICTURE_DEPLOY: + return { statusId: action.statusId, accountId: action.accountId, type: action.playerType, ...action.props }; + case PICTURE_IN_PICTURE_REMOVE: + return { ...initialState }; + default: + return state; + } +}; diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index 56d658d97..337005a59 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -144,7 +144,8 @@ } .icon-button { - display: inline-block; + display: inline-flex; + align-items: center; padding: 0; color: $action-button-color; border: 0; @@ -226,6 +227,14 @@ background: rgba($base-overlay-background, 0.9); } } + + &__counter { + display: inline-block; + width: 14px; + margin-left: 4px; + font-size: 12px; + font-weight: 500; + } } .text-icon-button { diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index ba75e3ffe..c589d935a 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -564,24 +564,6 @@ align-items: center; display: flex; margin-top: 8px; - - &__counter { - display: inline-flex; - margin-right: 11px; - align-items: center; - - .status__action-bar-button { - margin-right: 4px; - } - - &__label { - display: inline-block; - width: 14px; - font-size: 12px; - font-weight: 500; - color: $action-button-color; - } - } } .status__action-bar-button { @@ -1073,3 +1055,100 @@ a.status-card.compact:hover { } } } + +.picture-in-picture { + position: fixed; + bottom: 20px; + right: 20px; + width: 300px; + + &__footer { + border-radius: 0 0 4px 4px; + background: lighten($ui-base-color, 4%); + padding: 10px; + padding-top: 12px; + display: flex; + justify-content: space-between; + } + + &__header { + border-radius: 4px 4px 0 0; + background: lighten($ui-base-color, 4%); + padding: 10px; + display: flex; + justify-content: space-between; + + &__account { + display: flex; + text-decoration: none; + } + + .account__avatar { + margin-right: 10px; + } + + .display-name { + color: $primary-text-color; + text-decoration: none; + + strong, + span { + display: block; + text-overflow: ellipsis; + overflow: hidden; + } + + span { + color: $darker-text-color; + } + } + } + + .video-player, + .audio-player { + border-radius: 0; + } + + @media screen and (max-width: 415px) { + width: 210px; + bottom: 10px; + right: 10px; + + &__footer { + display: none; + } + + .video-player, + .audio-player { + border-radius: 0 0 4px 4px; + } + } +} + +.picture-in-picture-placeholder { + box-sizing: border-box; + border: 2px dashed lighten($ui-base-color, 8%); + background: $base-shadow-color; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-top: 10px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + color: $darker-text-color; + + i { + display: block; + font-size: 24px; + font-weight: 400; + margin-bottom: 10px; + } + + &:hover, + &:focus, + &:active { + border-color: lighten($ui-base-color, 12%); + } +} |