parent
							
								
									5bbc9a4f78
								
							
						
					
					
						commit
						d88a79b456
					
				
					 20 changed files with 648 additions and 58 deletions
				
			
		
							
								
								
									
										38
									
								
								app/javascript/mastodon/actions/picture_in_picture.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								app/javascript/mastodon/actions/picture_in_picture.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -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, | ||||
| }); | ||||
|  | @ -5,10 +5,21 @@ import TransitionMotion from 'react-motion/lib/TransitionMotion'; | |||
| import spring from 'react-motion/lib/spring'; | ||||
| import { reduceMotion } from 'mastodon/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> | ||||
|         )} | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ import React from 'react'; | |||
| import PropTypes from 'prop-types'; | ||||
| import classNames from 'classnames'; | ||||
| import Icon from 'mastodon/components/icon'; | ||||
| import AnimatedNumber from 'mastodon/components/animated_number'; | ||||
| 
 | ||||
| export default class IconButton extends React.PureComponent { | ||||
| 
 | ||||
|  | @ -24,6 +25,8 @@ export default class IconButton extends React.PureComponent { | |||
|     animate: PropTypes.bool, | ||||
|     overlay: PropTypes.bool, | ||||
|     tabIndex: PropTypes.string, | ||||
|     counter: PropTypes.number, | ||||
|     obfuscateCount: PropTypes.bool, | ||||
|   }; | ||||
| 
 | ||||
|   static defaultProps = { | ||||
|  | @ -97,6 +100,8 @@ export default class IconButton extends React.PureComponent { | |||
|       pressed, | ||||
|       tabIndex, | ||||
|       title, | ||||
|       counter, | ||||
|       obfuscateCount, | ||||
|     } = this.props; | ||||
| 
 | ||||
|     const { | ||||
|  | @ -113,6 +118,10 @@ export default class IconButton extends React.PureComponent { | |||
|       overlayed: overlay, | ||||
|     }); | ||||
| 
 | ||||
|     if (typeof counter !== 'undefined') { | ||||
|       style.width = 'auto'; | ||||
|     } | ||||
| 
 | ||||
|     return ( | ||||
|       <button | ||||
|         aria-label={title} | ||||
|  | @ -128,7 +137,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>} | ||||
|       </button> | ||||
|     ); | ||||
|   } | ||||
|  |  | |||
|  | @ -0,0 +1,69 @@ | |||
| import React from 'react'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import Icon from 'mastodon/components/icon'; | ||||
| import { removePictureInPicture } from 'mastodon/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> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
|  | @ -17,6 +17,7 @@ import { HotKeys } from 'react-hotkeys'; | |||
| import classNames from 'classnames'; | ||||
| import Icon from 'mastodon/components/icon'; | ||||
| import { displayMedia } from '../initial_state'; | ||||
| import PictureInPicturePlaceholder from 'mastodon/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
 | ||||
|  | @ -95,6 +96,8 @@ class Status extends ImmutablePureComponent { | |||
|     cacheMediaWidth: PropTypes.func, | ||||
|     cachedMediaWidth: PropTypes.number, | ||||
|     scrollKey: PropTypes.string, | ||||
|     deployPictureInPicture: PropTypes.func, | ||||
|     usingPiP: PropTypes.bool, | ||||
|   }; | ||||
| 
 | ||||
|   // Avoid checking props that are functions (and whose equality will always
 | ||||
|  | @ -105,6 +108,7 @@ class Status extends ImmutablePureComponent { | |||
|     'muted', | ||||
|     'hidden', | ||||
|     'unread', | ||||
|     'usingPiP', | ||||
|   ]; | ||||
| 
 | ||||
|   state = { | ||||
|  | @ -206,6 +210,13 @@ class Status extends ImmutablePureComponent { | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   handleDeployPictureInPicture = (type, mediaProps) => { | ||||
|     const { deployPictureInPicture } = this.props; | ||||
|     const status = this._properStatus(); | ||||
| 
 | ||||
|     deployPictureInPicture(status, type, mediaProps); | ||||
|   } | ||||
| 
 | ||||
|   handleHotkeyReply = e => { | ||||
|     e.preventDefault(); | ||||
|     this.props.onReply(this._properStatus(), this.context.router.history); | ||||
|  | @ -266,7 +277,7 @@ class Status extends ImmutablePureComponent { | |||
|     let media = null; | ||||
|     let statusAvatar, prepend, rebloggedByText; | ||||
| 
 | ||||
|     const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey } = this.props; | ||||
|     const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, usingPiP } = this.props; | ||||
| 
 | ||||
|     let { status, account, ...other } = this.props; | ||||
| 
 | ||||
|  | @ -337,7 +348,9 @@ class Status extends ImmutablePureComponent { | |||
|       status  = status.get('reblog'); | ||||
|     } | ||||
| 
 | ||||
|     if (status.get('media_attachments').size > 0) { | ||||
|     if (usingPiP) { | ||||
|       media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />; | ||||
|     } else if (status.get('media_attachments').size > 0) { | ||||
|       if (this.props.muted) { | ||||
|         media = ( | ||||
|           <AttachmentList | ||||
|  | @ -362,6 +375,7 @@ class Status extends ImmutablePureComponent { | |||
|                 width={this.props.cachedMediaWidth} | ||||
|                 height={110} | ||||
|                 cacheWidth={this.props.cacheMediaWidth} | ||||
|                 deployPictureInPicture={this.handleDeployPictureInPicture} | ||||
|               /> | ||||
|             )} | ||||
|           </Bundle> | ||||
|  | @ -383,6 +397,7 @@ class Status extends ImmutablePureComponent { | |||
|                 sensitive={status.get('sensitive')} | ||||
|                 onOpenVideo={this.handleOpenVideo} | ||||
|                 cacheWidth={this.props.cacheMediaWidth} | ||||
|                 deployPictureInPicture={this.handleDeployPictureInPicture} | ||||
|                 visible={this.state.showMedia} | ||||
|                 onToggleVisibility={this.handleToggleMediaVisibility} | ||||
|               /> | ||||
|  |  | |||
|  | @ -43,16 +43,6 @@ const messages = defineMessages({ | |||
|   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, | ||||
| }); | ||||
| 
 | ||||
| const obfuscatedCount = count => { | ||||
|   if (count < 0) { | ||||
|     return 0; | ||||
|   } else if (count <= 1) { | ||||
|     return count; | ||||
|   } else { | ||||
|     return '1+'; | ||||
|   } | ||||
| }; | ||||
| 
 | ||||
| const mapStateToProps = (state, { status }) => ({ | ||||
|   relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), | ||||
| }); | ||||
|  | @ -329,9 +319,10 @@ class StatusActionBar extends ImmutablePureComponent { | |||
| 
 | ||||
|     return ( | ||||
|       <div className='status__action-bar'> | ||||
|         <div className='status__action-bar__counter'><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} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div> | ||||
|         <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} /> | ||||
|         <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} /> | ||||
| 
 | ||||
|         {shareButton} | ||||
| 
 | ||||
|         <div className='status__action-bar-dropdown'> | ||||
|  |  | |||
|  | @ -37,6 +37,7 @@ import { initMuteModal } from '../actions/mutes'; | |||
| import { initBlockModal } from '../actions/blocks'; | ||||
| import { initReport } from '../actions/reports'; | ||||
| import { openModal } from '../actions/modal'; | ||||
| import { deployPictureInPicture } from '../actions/picture_in_picture'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| import { boostModal, deleteModal } from '../initial_state'; | ||||
| import { showAlertForError } from '../actions/alerts'; | ||||
|  | @ -56,6 +57,7 @@ const makeMapStateToProps = () => { | |||
| 
 | ||||
|   const mapStateToProps = (state, props) => ({ | ||||
|     status: getStatus(state, props), | ||||
|     usingPiP: state.get('picture_in_picture').statusId === props.id, | ||||
|   }); | ||||
| 
 | ||||
|   return mapStateToProps; | ||||
|  | @ -207,6 +209,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ | |||
|     dispatch(unblockDomain(domain)); | ||||
|   }, | ||||
| 
 | ||||
|   deployPictureInPicture (status, type, mediaProps) { | ||||
|     dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps)); | ||||
|   }, | ||||
| 
 | ||||
| }); | ||||
| 
 | ||||
| export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); | ||||
|  |  | |||
|  | @ -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)); | ||||
|  | @ -112,6 +129,10 @@ 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 = () => { | ||||
|  | @ -248,7 +269,13 @@ class Audio extends React.PureComponent { | |||
|     const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); | ||||
| 
 | ||||
|     if (!this.state.paused && !inView) { | ||||
|       this.setState({ paused: true }, () => this.audio.pause()); | ||||
|       this.audio.pause(); | ||||
| 
 | ||||
|       if (this.props.deployPictureInPicture) { | ||||
|         this.props.deployPictureInPicture('audio', this._pack()); | ||||
|       } | ||||
| 
 | ||||
|       this.setState({ paused: true }); | ||||
|     } | ||||
|   }, 150, { trailing: true }); | ||||
| 
 | ||||
|  | @ -261,10 +288,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(); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | @ -350,7 +389,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}> | ||||
|  |  | |||
|  | @ -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 'mastodon/components/icon_button'; | ||||
| import classNames from 'classnames'; | ||||
| import { me, boostModal } from 'mastodon/initial_state'; | ||||
| import { defineMessages, injectIntl } from 'react-intl'; | ||||
| import { replyCompose } from 'mastodon/actions/compose'; | ||||
| import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions'; | ||||
| import { makeGetStatus } from 'mastodon/selectors'; | ||||
| import { openModal } from 'mastodon/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> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
|  | @ -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 'mastodon/components/icon_button'; | ||||
| import { Link } from 'react-router-dom'; | ||||
| import Avatar from 'mastodon/components/avatar'; | ||||
| import DisplayName from 'mastodon/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> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										85
									
								
								app/javascript/mastodon/features/picture_in_picture/index.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								app/javascript/mastodon/features/picture_in_picture/index.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,85 @@ | |||
| import React from 'react'; | ||||
| import { connect } from 'react-redux'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import Video from 'mastodon/features/video'; | ||||
| import Audio from 'mastodon/features/audio'; | ||||
| import { removePictureInPicture } from 'mastodon/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> | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
| } | ||||
|  | @ -15,6 +15,7 @@ import scheduleIdleTask from '../../ui/util/schedule_idle_task'; | |||
| import classNames from 'classnames'; | ||||
| import Icon from 'mastodon/components/icon'; | ||||
| import AnimatedNumber from 'mastodon/components/animated_number'; | ||||
| import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder'; | ||||
| 
 | ||||
| const messages = defineMessages({ | ||||
|   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, | ||||
|  | @ -40,6 +41,7 @@ class DetailedStatus extends ImmutablePureComponent { | |||
|     domain: PropTypes.string.isRequired, | ||||
|     compact: PropTypes.bool, | ||||
|     showMedia: PropTypes.bool, | ||||
|     usingPiP: PropTypes.bool, | ||||
|     onToggleMediaVisibility: PropTypes.func, | ||||
|   }; | ||||
| 
 | ||||
|  | @ -100,7 +102,7 @@ class DetailedStatus extends ImmutablePureComponent { | |||
|   render () { | ||||
|     const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; | ||||
|     const outerStyle = { boxSizing: 'border-box' }; | ||||
|     const { intl, compact } = this.props; | ||||
|     const { intl, compact, usingPiP } = this.props; | ||||
| 
 | ||||
|     if (!status) { | ||||
|       return null; | ||||
|  | @ -116,7 +118,9 @@ class DetailedStatus extends ImmutablePureComponent { | |||
|       outerStyle.height = `${this.state.height}px`; | ||||
|     } | ||||
| 
 | ||||
|     if (status.get('media_attachments').size > 0) { | ||||
|     if (usingPiP) { | ||||
|       media = <PictureInPicturePlaceholder />; | ||||
|     } else if (status.get('media_attachments').size > 0) { | ||||
|       if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { | ||||
|         const attachment = status.getIn(['media_attachments', 0]); | ||||
| 
 | ||||
|  |  | |||
|  | @ -143,6 +143,7 @@ const makeMapStateToProps = () => { | |||
|       descendantsIds, | ||||
|       askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, | ||||
|       domain: state.getIn(['meta', 'domain']), | ||||
|       usingPiP: state.get('picture_in_picture').statusId === props.params.statusId, | ||||
|     }; | ||||
|   }; | ||||
| 
 | ||||
|  | @ -167,6 +168,7 @@ class Status extends ImmutablePureComponent { | |||
|     askReplyConfirmation: PropTypes.bool, | ||||
|     multiColumn: PropTypes.bool, | ||||
|     domain: PropTypes.string.isRequired, | ||||
|     usingPiP: PropTypes.bool, | ||||
|   }; | ||||
| 
 | ||||
|   state = { | ||||
|  | @ -492,7 +494,7 @@ class Status extends ImmutablePureComponent { | |||
| 
 | ||||
|   render () { | ||||
|     let ancestors, descendants; | ||||
|     const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn } = this.props; | ||||
|     const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, usingPiP } = this.props; | ||||
|     const { fullscreen } = this.state; | ||||
| 
 | ||||
|     if (status === null) { | ||||
|  | @ -550,6 +552,7 @@ class Status extends ImmutablePureComponent { | |||
|                   domain={domain} | ||||
|                   showMedia={this.state.showMedia} | ||||
|                   onToggleMediaVisibility={this.handleToggleMediaVisibility} | ||||
|                   usingPiP={usingPiP} | ||||
|                 /> | ||||
| 
 | ||||
|                 <ActionBar | ||||
|  |  | |||
|  | @ -160,7 +160,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')} | ||||
|  |  | |||
|  | @ -66,9 +66,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')} | ||||
|  |  | |||
|  | @ -21,6 +21,7 @@ import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; | |||
| import UploadArea from './components/upload_area'; | ||||
| import ColumnsAreaContainer from './containers/columns_area_container'; | ||||
| import DocumentTitle from './components/document_title'; | ||||
| import PictureInPicture from 'mastodon/features/picture_in_picture'; | ||||
| import { | ||||
|   Compose, | ||||
|   Status, | ||||
|  | @ -547,6 +548,7 @@ class UI extends React.PureComponent { | |||
|             {children} | ||||
|           </SwitchingColumnsArea> | ||||
| 
 | ||||
|           <PictureInPicture /> | ||||
|           <NotificationsContainer /> | ||||
|           <LoadingBarContainer className='loading-bar' /> | ||||
|           <ModalContainer /> | ||||
|  |  | |||
|  | @ -104,20 +104,23 @@ 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, | ||||
|     detailed: PropTypes.bool, | ||||
|     inline: PropTypes.bool, | ||||
|     editable: PropTypes.bool, | ||||
|     alwaysVisible: PropTypes.bool, | ||||
|     cacheWidth: PropTypes.func, | ||||
|     visible: PropTypes.bool, | ||||
|     onToggleVisibility: PropTypes.func, | ||||
|     deployPictureInPicture: PropTypes.func, | ||||
|     intl: PropTypes.object.isRequired, | ||||
|     blurhash: PropTypes.string, | ||||
|     link: PropTypes.node, | ||||
|     autoPlay: PropTypes.bool, | ||||
|     defaultVolume: PropTypes.number, | ||||
|     volume: PropTypes.number, | ||||
|     muted: PropTypes.bool, | ||||
|   }; | ||||
| 
 | ||||
|   state = { | ||||
|  | @ -297,6 +300,15 @@ class Video extends React.PureComponent { | |||
|     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, | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   componentWillReceiveProps (nextProps) { | ||||
|  | @ -328,7 +340,18 @@ class Video extends React.PureComponent { | |||
|     const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); | ||||
| 
 | ||||
|     if (!this.state.paused && !inView) { | ||||
|       this.setState({ paused: true }, () => this.video.pause()); | ||||
|       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 }) | ||||
| 
 | ||||
|  | @ -361,15 +384,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 (this.props.defaultVolume !== undefined) { | ||||
|       this.video.volume = this.props.defaultVolume; | ||||
|     if (volume !== undefined) { | ||||
|       this.video.volume = volume; | ||||
|     } | ||||
| 
 | ||||
|     if (this.props.autoPlay) { | ||||
|     if (muted !== undefined) { | ||||
|       this.video.muted = muted; | ||||
|     } | ||||
| 
 | ||||
|     if (autoPlay) { | ||||
|       this.video.play(); | ||||
|     } | ||||
|   } | ||||
|  | @ -414,9 +443,9 @@ class Video extends React.PureComponent { | |||
|   } | ||||
| 
 | ||||
|   render () { | ||||
|     const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, editable, blurhash } = this.props; | ||||
|     const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, 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 = {}; | ||||
| 
 | ||||
|     let { width, height } = this.props; | ||||
|  | @ -430,7 +459,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'; | ||||
|  | @ -530,7 +559,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> | ||||
|  |  | |||
|  | @ -36,6 +36,7 @@ import trends from './trends'; | |||
| import missed_updates from './missed_updates'; | ||||
| import announcements from './announcements'; | ||||
| import markers from './markers'; | ||||
| import picture_in_picture from './picture_in_picture'; | ||||
| 
 | ||||
| const reducers = { | ||||
|   announcements, | ||||
|  | @ -75,6 +76,7 @@ const reducers = { | |||
|   trends, | ||||
|   missed_updates, | ||||
|   markers, | ||||
|   picture_in_picture, | ||||
| }; | ||||
| 
 | ||||
| export default combineReducers(reducers); | ||||
|  |  | |||
							
								
								
									
										22
									
								
								app/javascript/mastodon/reducers/picture_in_picture.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								app/javascript/mastodon/reducers/picture_in_picture.js
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,22 @@ | |||
| import { PICTURE_IN_PICTURE_DEPLOY, PICTURE_IN_PICTURE_REMOVE } from 'mastodon/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; | ||||
|   } | ||||
| }; | ||||
|  | @ -163,7 +163,8 @@ | |||
| } | ||||
| 
 | ||||
| .icon-button { | ||||
|   display: inline-block; | ||||
|   display: inline-flex; | ||||
|   align-items: center; | ||||
|   padding: 0; | ||||
|   color: $action-button-color; | ||||
|   border: 0; | ||||
|  | @ -245,6 +246,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 { | ||||
|  | @ -1139,24 +1148,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 { | ||||
|  | @ -7034,3 +7025,100 @@ noscript { | |||
|     } | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| .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%); | ||||
|   } | ||||
| } | ||||
|  |  | |||
		Reference in a new issue