[Glitch] Add pop-out player for audio/video in web UI
port fc497420e9 to glitch-soc
Signed-off-by: Thibaut Girka <thib@sitedethib.com>
			
			
This commit is contained in:
		
							parent
							
								
									d47b849501
								
							
						
					
					
						commit
						f08b14ce71
					
				
					 21 changed files with 681 additions and 56 deletions
				
			
		
							
								
								
									
										38
									
								
								app/javascript/flavours/glitch/actions/picture_in_picture.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								app/javascript/flavours/glitch/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 spring from 'react-motion/lib/spring'; | ||||||
| import { reduceMotion } from 'flavours/glitch/util/initial_state'; | 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 { | export default class AnimatedNumber extends React.PureComponent { | ||||||
| 
 | 
 | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     value: PropTypes.number.isRequired, |     value: PropTypes.number.isRequired, | ||||||
|  |     obfuscate: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   state = { |   state = { | ||||||
|  | @ -36,11 +47,11 @@ export default class AnimatedNumber extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { value } = this.props; |     const { value, obfuscate } = this.props; | ||||||
|     const { direction } = this.state; |     const { direction } = this.state; | ||||||
| 
 | 
 | ||||||
|     if (reduceMotion) { |     if (reduceMotion) { | ||||||
|       return <FormattedNumber value={value} />; |       return obfuscate ? obfuscatedCount(value) : <FormattedNumber value={value} />; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const styles = [{ |     const styles = [{ | ||||||
|  | @ -54,7 +65,7 @@ export default class AnimatedNumber extends React.PureComponent { | ||||||
|         {items => ( |         {items => ( | ||||||
|           <span className='animated-number'> |           <span className='animated-number'> | ||||||
|             {items.map(({ key, data, style }) => ( |             {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> |           </span> | ||||||
|         )} |         )} | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ import spring from 'react-motion/lib/spring'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||||
| import Icon from 'flavours/glitch/components/icon'; | import Icon from 'flavours/glitch/components/icon'; | ||||||
|  | import AnimatedNumber from 'flavours/glitch/components/animated_number'; | ||||||
| 
 | 
 | ||||||
| export default class IconButton extends React.PureComponent { | export default class IconButton extends React.PureComponent { | ||||||
| 
 | 
 | ||||||
|  | @ -27,6 +28,8 @@ export default class IconButton extends React.PureComponent { | ||||||
|     overlay: PropTypes.bool, |     overlay: PropTypes.bool, | ||||||
|     tabIndex: PropTypes.string, |     tabIndex: PropTypes.string, | ||||||
|     label: PropTypes.string, |     label: PropTypes.string, | ||||||
|  |     counter: PropTypes.number, | ||||||
|  |     obfuscateCount: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   static defaultProps = { |   static defaultProps = { | ||||||
|  | @ -104,6 +107,8 @@ export default class IconButton extends React.PureComponent { | ||||||
|       pressed, |       pressed, | ||||||
|       tabIndex, |       tabIndex, | ||||||
|       title, |       title, | ||||||
|  |       counter, | ||||||
|  |       obfuscateCount, | ||||||
|     } = this.props; |     } = this.props; | ||||||
| 
 | 
 | ||||||
|     const { |     const { | ||||||
|  | @ -120,6 +125,10 @@ export default class IconButton extends React.PureComponent { | ||||||
|       overlayed: overlay, |       overlayed: overlay, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |     if (typeof counter !== 'undefined') { | ||||||
|  |       style.width = 'auto'; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     return ( |     return ( | ||||||
|       <button |       <button | ||||||
|         aria-label={title} |         aria-label={title} | ||||||
|  | @ -135,7 +144,7 @@ export default class IconButton extends React.PureComponent { | ||||||
|         tabIndex={tabIndex} |         tabIndex={tabIndex} | ||||||
|         disabled={disabled} |         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} |         {this.props.label} | ||||||
|       </button> |       </button> | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|  | @ -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> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -17,6 +17,7 @@ import classNames from 'classnames'; | ||||||
| import { autoUnfoldCW } from 'flavours/glitch/util/content_warning'; | import { autoUnfoldCW } from 'flavours/glitch/util/content_warning'; | ||||||
| import PollContainer from 'flavours/glitch/containers/poll_container'; | import PollContainer from 'flavours/glitch/containers/poll_container'; | ||||||
| import { displayMedia } from 'flavours/glitch/util/initial_state'; | 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
 | // We use the component (and not the container) since we do not want
 | ||||||
| // to use the progress bar to show download progress
 | // to use the progress bar to show download progress
 | ||||||
|  | @ -97,6 +98,8 @@ class Status extends ImmutablePureComponent { | ||||||
|     cachedMediaWidth: PropTypes.number, |     cachedMediaWidth: PropTypes.number, | ||||||
|     onClick: PropTypes.func, |     onClick: PropTypes.func, | ||||||
|     scrollKey: PropTypes.string, |     scrollKey: PropTypes.string, | ||||||
|  |     deployPictureInPicture: PropTypes.func, | ||||||
|  |     usingPiP: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   state = { |   state = { | ||||||
|  | @ -123,6 +126,7 @@ class Status extends ImmutablePureComponent { | ||||||
|     'hidden', |     'hidden', | ||||||
|     'expanded', |     'expanded', | ||||||
|     'unread', |     'unread', | ||||||
|  |     'usingPiP', | ||||||
|   ] |   ] | ||||||
| 
 | 
 | ||||||
|   updateOnStates = [ |   updateOnStates = [ | ||||||
|  | @ -394,6 +398,12 @@ class Status extends ImmutablePureComponent { | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   handleDeployPictureInPicture = (type, mediaProps) => { | ||||||
|  |     const { deployPictureInPicture, status } = this.props; | ||||||
|  | 
 | ||||||
|  |     deployPictureInPicture(status, type, mediaProps); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   handleHotkeyReply = e => { |   handleHotkeyReply = e => { | ||||||
|     e.preventDefault(); |     e.preventDefault(); | ||||||
|     this.props.onReply(this.props.status, this.context.router.history); |     this.props.onReply(this.props.status, this.context.router.history); | ||||||
|  | @ -496,6 +506,7 @@ class Status extends ImmutablePureComponent { | ||||||
|       hidden, |       hidden, | ||||||
|       unread, |       unread, | ||||||
|       featured, |       featured, | ||||||
|  |       usingPiP, | ||||||
|       ...other |       ...other | ||||||
|     } = this.props; |     } = this.props; | ||||||
|     const { isExpanded, isCollapsed, forceFilter } = this.state; |     const { isExpanded, isCollapsed, forceFilter } = this.state; | ||||||
|  | @ -576,6 +587,9 @@ class Status extends ImmutablePureComponent { | ||||||
|     if (status.get('poll')) { |     if (status.get('poll')) { | ||||||
|       media = <PollContainer pollId={status.get('poll')} />; |       media = <PollContainer pollId={status.get('poll')} />; | ||||||
|       mediaIcon = 'tasks'; |       mediaIcon = 'tasks'; | ||||||
|  |     } else if (usingPiP) { | ||||||
|  |       media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />; | ||||||
|  |       mediaIcon = 'video-camera'; | ||||||
|     } else if (attachments.size > 0) { |     } else if (attachments.size > 0) { | ||||||
|       if (muted || attachments.some(item => item.get('type') === 'unknown')) { |       if (muted || attachments.some(item => item.get('type') === 'unknown')) { | ||||||
|         media = ( |         media = ( | ||||||
|  | @ -601,6 +615,7 @@ class Status extends ImmutablePureComponent { | ||||||
|                 width={this.props.cachedMediaWidth} |                 width={this.props.cachedMediaWidth} | ||||||
|                 height={110} |                 height={110} | ||||||
|                 cacheWidth={this.props.cacheMediaWidth} |                 cacheWidth={this.props.cacheMediaWidth} | ||||||
|  |                 deployPictureInPicture={this.handleDeployPictureInPicture} | ||||||
|               /> |               /> | ||||||
|             )} |             )} | ||||||
|           </Bundle> |           </Bundle> | ||||||
|  | @ -624,6 +639,7 @@ class Status extends ImmutablePureComponent { | ||||||
|               onOpenVideo={this.handleOpenVideo} |               onOpenVideo={this.handleOpenVideo} | ||||||
|               width={this.props.cachedMediaWidth} |               width={this.props.cachedMediaWidth} | ||||||
|               cacheWidth={this.props.cacheMediaWidth} |               cacheWidth={this.props.cacheMediaWidth} | ||||||
|  |               deployPictureInPicture={this.handleDeployPictureInPicture} | ||||||
|               visible={this.state.showMedia} |               visible={this.state.showMedia} | ||||||
|               onToggleVisibility={this.handleToggleMediaVisibility} |               onToggleVisibility={this.handleToggleMediaVisibility} | ||||||
|             />)} |             />)} | ||||||
|  |  | ||||||
|  | @ -40,16 +40,6 @@ const messages = defineMessages({ | ||||||
|   hide: { id: 'status.hide', defaultMessage: 'Hide toot' }, |   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 | export default @injectIntl | ||||||
| class StatusActionBar extends ImmutablePureComponent { | class StatusActionBar extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|  | @ -284,10 +274,14 @@ class StatusActionBar extends ImmutablePureComponent { | ||||||
|     ); |     ); | ||||||
|     if (showReplyCount) { |     if (showReplyCount) { | ||||||
|       replyButton = ( |       replyButton = ( | ||||||
|         <div className='status__action-bar__counter'> |         <IconButton | ||||||
|           {replyButton} |           className='status__action-bar-button' | ||||||
|           <span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span> |           title={replyTitle} | ||||||
|         </div> |           icon={replyIcon} | ||||||
|  |           onClick={this.handleReplyClick} | ||||||
|  |           counter={status.get('replies_count')} | ||||||
|  |           obfuscateCount | ||||||
|  |         /> | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -22,6 +22,7 @@ import { initMuteModal } from 'flavours/glitch/actions/mutes'; | ||||||
| import { initBlockModal } from 'flavours/glitch/actions/blocks'; | import { initBlockModal } from 'flavours/glitch/actions/blocks'; | ||||||
| import { initReport } from 'flavours/glitch/actions/reports'; | import { initReport } from 'flavours/glitch/actions/reports'; | ||||||
| import { openModal } from 'flavours/glitch/actions/modal'; | 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 { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state'; | import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state'; | ||||||
|  | @ -69,6 +70,7 @@ const makeMapStateToProps = () => { | ||||||
|       account     : account || props.account, |       account     : account || props.account, | ||||||
|       settings    : state.get('local_settings'), |       settings    : state.get('local_settings'), | ||||||
|       prepend     : prepend || props.prepend, |       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)); | export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); | ||||||
|  |  | ||||||
|  | @ -37,7 +37,11 @@ class Audio extends React.PureComponent { | ||||||
|     backgroundColor: PropTypes.string, |     backgroundColor: PropTypes.string, | ||||||
|     foregroundColor: PropTypes.string, |     foregroundColor: PropTypes.string, | ||||||
|     accentColor: PropTypes.string, |     accentColor: PropTypes.string, | ||||||
|  |     currentTime: PropTypes.number, | ||||||
|     autoPlay: PropTypes.bool, |     autoPlay: PropTypes.bool, | ||||||
|  |     volume: PropTypes.number, | ||||||
|  |     muted: PropTypes.bool, | ||||||
|  |     deployPictureInPicture: PropTypes.func, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   state = { |   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 () { |   _setDimensions () { | ||||||
|     const width  = this.player.offsetWidth; |     const width  = this.player.offsetWidth; | ||||||
|     const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9)); |     const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9)); | ||||||
|  | @ -100,6 +117,7 @@ class Audio extends React.PureComponent { | ||||||
|   } |   } | ||||||
|   |   | ||||||
|   componentDidMount () { |   componentDidMount () { | ||||||
|  |     window.addEventListener('scroll', this.handleScroll); | ||||||
|     window.addEventListener('resize', this.handleResize, { passive: true }); |     window.addEventListener('resize', this.handleResize, { passive: true }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -115,7 +133,12 @@ class Audio extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   componentWillUnmount () { |   componentWillUnmount () { | ||||||
|  |     window.removeEventListener('scroll', this.handleScroll); | ||||||
|     window.removeEventListener('resize', this.handleResize); |     window.removeEventListener('resize', this.handleResize); | ||||||
|  | 
 | ||||||
|  |     if (!this.state.paused && this.audio && this.props.deployPictureInPicture) { | ||||||
|  |       this.props.deployPictureInPicture('audio', this._pack()); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   togglePlay = () => { |   togglePlay = () => { | ||||||
|  | @ -243,6 +266,25 @@ class Audio extends React.PureComponent { | ||||||
|     } |     } | ||||||
|   }, 15); |   }, 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 = () => { |   handleMouseEnter = () => { | ||||||
|     this.setState({ hovered: true }); |     this.setState({ hovered: true }); | ||||||
|   } |   } | ||||||
|  | @ -252,10 +294,22 @@ class Audio extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleLoadedData = () => { |   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) { |     if (autoPlay) { | ||||||
|       this.audio.play(); |       this.togglePlay(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -341,7 +395,7 @@ class Audio extends React.PureComponent { | ||||||
|   render () { |   render () { | ||||||
|     const { src, intl, alt, editable, autoPlay } = this.props; |     const { src, intl, alt, editable, autoPlay } = this.props; | ||||||
|     const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state; |     const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state; | ||||||
|     const progress = (currentTime / duration) * 100; |     const progress = Math.min((currentTime / duration) * 100, 100); | ||||||
| 
 | 
 | ||||||
|     return ( |     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}> |       <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 '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> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -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> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -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> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -18,6 +18,7 @@ import classNames from 'classnames'; | ||||||
| import PollContainer from 'flavours/glitch/containers/poll_container'; | import PollContainer from 'flavours/glitch/containers/poll_container'; | ||||||
| import Icon from 'flavours/glitch/components/icon'; | import Icon from 'flavours/glitch/components/icon'; | ||||||
| import AnimatedNumber from 'flavours/glitch/components/animated_number'; | import AnimatedNumber from 'flavours/glitch/components/animated_number'; | ||||||
|  | import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; | ||||||
| 
 | 
 | ||||||
| export default class DetailedStatus extends ImmutablePureComponent { | export default class DetailedStatus extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|  | @ -37,6 +38,7 @@ export default class DetailedStatus extends ImmutablePureComponent { | ||||||
|     domain: PropTypes.string.isRequired, |     domain: PropTypes.string.isRequired, | ||||||
|     compact: PropTypes.bool, |     compact: PropTypes.bool, | ||||||
|     showMedia: PropTypes.bool, |     showMedia: PropTypes.bool, | ||||||
|  |     usingPiP: PropTypes.bool, | ||||||
|     onToggleMediaVisibility: PropTypes.func, |     onToggleMediaVisibility: PropTypes.func, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|  | @ -109,7 +111,7 @@ export default class DetailedStatus extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; |     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 outerStyle = { boxSizing: 'border-box' }; | ||||||
|     const { compact } = this.props; |     const { compact } = this.props; | ||||||
| 
 | 
 | ||||||
|  | @ -131,6 +133,9 @@ export default class DetailedStatus extends ImmutablePureComponent { | ||||||
|     if (status.get('poll')) { |     if (status.get('poll')) { | ||||||
|       media = <PollContainer pollId={status.get('poll')} />; |       media = <PollContainer pollId={status.get('poll')} />; | ||||||
|       mediaIcon = 'tasks'; |       mediaIcon = 'tasks'; | ||||||
|  |     } else if (usingPiP) { | ||||||
|  |       media = <PictureInPicturePlaceholder />; | ||||||
|  |       mediaIcon = 'video-camera'; | ||||||
|     } else if (status.get('media_attachments').size > 0) { |     } else if (status.get('media_attachments').size > 0) { | ||||||
|       if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { |       if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { | ||||||
|         media = <AttachmentList media={status.get('media_attachments')} />; |         media = <AttachmentList media={status.get('media_attachments')} />; | ||||||
|  |  | ||||||
|  | @ -132,6 +132,7 @@ const makeMapStateToProps = () => { | ||||||
|       settings: state.get('local_settings'), |       settings: state.get('local_settings'), | ||||||
|       askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0, |       askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0, | ||||||
|       domain: state.getIn(['meta', 'domain']), |       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, |     askReplyConfirmation: PropTypes.bool, | ||||||
|     multiColumn: PropTypes.bool, |     multiColumn: PropTypes.bool, | ||||||
|     domain: PropTypes.string.isRequired, |     domain: PropTypes.string.isRequired, | ||||||
|  |     usingPiP: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   state = { |   state = { | ||||||
|  | @ -514,7 +516,7 @@ class Status extends ImmutablePureComponent { | ||||||
|   render () { |   render () { | ||||||
|     let ancestors, descendants; |     let ancestors, descendants; | ||||||
|     const { setExpansion } = this; |     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; |     const { fullscreen, isExpanded } = this.state; | ||||||
| 
 | 
 | ||||||
|     if (status === null) { |     if (status === null) { | ||||||
|  | @ -578,6 +580,7 @@ class Status extends ImmutablePureComponent { | ||||||
|                   domain={domain} |                   domain={domain} | ||||||
|                   showMedia={this.state.showMedia} |                   showMedia={this.state.showMedia} | ||||||
|                   onToggleMediaVisibility={this.handleToggleMediaVisibility} |                   onToggleMediaVisibility={this.handleToggleMediaVisibility} | ||||||
|  |                   usingPiP={usingPiP} | ||||||
|                 /> |                 /> | ||||||
| 
 | 
 | ||||||
|                 <ActionBar |                 <ActionBar | ||||||
|  |  | ||||||
|  | @ -140,7 +140,7 @@ class MediaModal extends ImmutablePureComponent { | ||||||
|             src={image.get('url')} |             src={image.get('url')} | ||||||
|             width={image.get('width')} |             width={image.get('width')} | ||||||
|             height={image.get('height')} |             height={image.get('height')} | ||||||
|             startTime={time || 0} |             currentTime={time || 0} | ||||||
|             onCloseVideo={onClose} |             onCloseVideo={onClose} | ||||||
|             detailed |             detailed | ||||||
|             alt={image.get('description')} |             alt={image.get('description')} | ||||||
|  |  | ||||||
|  | @ -42,9 +42,9 @@ export default class VideoModal extends ImmutablePureComponent { | ||||||
|             preview={media.get('preview_url')} |             preview={media.get('preview_url')} | ||||||
|             blurhash={media.get('blurhash')} |             blurhash={media.get('blurhash')} | ||||||
|             src={media.get('url')} |             src={media.get('url')} | ||||||
|             startTime={options.startTime} |             currentTime={options.startTime} | ||||||
|             autoPlay={options.autoPlay} |             autoPlay={options.autoPlay} | ||||||
|             defaultVolume={options.defaultVolume} |             volume={options.defaultVolume} | ||||||
|             onCloseVideo={onClose} |             onCloseVideo={onClose} | ||||||
|             detailed |             detailed | ||||||
|             alt={media.get('description')} |             alt={media.get('description')} | ||||||
|  |  | ||||||
|  | @ -19,6 +19,7 @@ import PermaLink from 'flavours/glitch/components/permalink'; | ||||||
| import ColumnsAreaContainer from './containers/columns_area_container'; | import ColumnsAreaContainer from './containers/columns_area_container'; | ||||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||||
| import Favico from 'favico.js'; | import Favico from 'favico.js'; | ||||||
|  | import PictureInPicture from 'flavours/glitch/features/picture_in_picture'; | ||||||
| import { | import { | ||||||
|   Compose, |   Compose, | ||||||
|   Status, |   Status, | ||||||
|  | @ -614,6 +615,7 @@ class UI extends React.Component { | ||||||
|             {children} |             {children} | ||||||
|           </SwitchingColumnsArea> |           </SwitchingColumnsArea> | ||||||
| 
 | 
 | ||||||
|  |           <PictureInPicture /> | ||||||
|           <NotificationsContainer /> |           <NotificationsContainer /> | ||||||
|           <LoadingBarContainer className='loading-bar' /> |           <LoadingBarContainer className='loading-bar' /> | ||||||
|           <ModalContainer /> |           <ModalContainer /> | ||||||
|  |  | ||||||
|  | @ -103,7 +103,7 @@ class Video extends React.PureComponent { | ||||||
|     width: PropTypes.number, |     width: PropTypes.number, | ||||||
|     height: PropTypes.number, |     height: PropTypes.number, | ||||||
|     sensitive: PropTypes.bool, |     sensitive: PropTypes.bool, | ||||||
|     startTime: PropTypes.number, |     currentTime: PropTypes.number, | ||||||
|     onOpenVideo: PropTypes.func, |     onOpenVideo: PropTypes.func, | ||||||
|     onCloseVideo: PropTypes.func, |     onCloseVideo: PropTypes.func, | ||||||
|     letterbox: PropTypes.bool, |     letterbox: PropTypes.bool, | ||||||
|  | @ -111,15 +111,18 @@ class Video extends React.PureComponent { | ||||||
|     detailed: PropTypes.bool, |     detailed: PropTypes.bool, | ||||||
|     inline: PropTypes.bool, |     inline: PropTypes.bool, | ||||||
|     editable: PropTypes.bool, |     editable: PropTypes.bool, | ||||||
|  |     alwaysVisible: PropTypes.bool, | ||||||
|     cacheWidth: PropTypes.func, |     cacheWidth: PropTypes.func, | ||||||
|     intl: PropTypes.object.isRequired, |     intl: PropTypes.object.isRequired, | ||||||
|     visible: PropTypes.bool, |     visible: PropTypes.bool, | ||||||
|     onToggleVisibility: PropTypes.func, |     onToggleVisibility: PropTypes.func, | ||||||
|  |     deployPictureInPicture: PropTypes.func, | ||||||
|     preventPlayback: PropTypes.bool, |     preventPlayback: PropTypes.bool, | ||||||
|     blurhash: PropTypes.string, |     blurhash: PropTypes.string, | ||||||
|     link: PropTypes.node, |     link: PropTypes.node, | ||||||
|     autoPlay: PropTypes.bool, |     autoPlay: PropTypes.bool, | ||||||
|     defaultVolume: PropTypes.number, |     volume: PropTypes.number, | ||||||
|  |     muted: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   state = { |   state = { | ||||||
|  | @ -298,16 +301,27 @@ class Video extends React.PureComponent { | ||||||
|     document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true); |     document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true); | ||||||
|     document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true); |     document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true); | ||||||
| 
 | 
 | ||||||
|  |     window.addEventListener('scroll', this.handleScroll); | ||||||
|     window.addEventListener('resize', this.handleResize, { passive: true }); |     window.addEventListener('resize', this.handleResize, { passive: true }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   componentWillUnmount () { |   componentWillUnmount () { | ||||||
|  |     window.removeEventListener('scroll', this.handleScroll); | ||||||
|     window.removeEventListener('resize', this.handleResize); |     window.removeEventListener('resize', this.handleResize); | ||||||
| 
 | 
 | ||||||
|     document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true); |     document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true); | ||||||
|     document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); |     document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); | ||||||
|     document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true); |     document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true); | ||||||
|     document.removeEventListener('MSFullscreenChange', 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) { |   componentDidUpdate (prevProps) { | ||||||
|  | @ -330,6 +344,30 @@ class Video extends React.PureComponent { | ||||||
|     trailing: true, |     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 = () => { |   handleFullscreenChange = () => { | ||||||
|     this.setState({ fullscreen: isFullscreen() }); |     this.setState({ fullscreen: isFullscreen() }); | ||||||
|   } |   } | ||||||
|  | @ -360,15 +398,21 @@ class Video extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleLoadedData = () => { |   handleLoadedData = () => { | ||||||
|     if (this.props.startTime) { |     const { currentTime, volume, muted, autoPlay } = this.props; | ||||||
|       this.video.currentTime = this.props.startTime; | 
 | ||||||
|  |     if (currentTime) { | ||||||
|  |       this.video.currentTime = currentTime; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (this.props.defaultVolume !== undefined) { |     if (volume !== undefined) { | ||||||
|       this.video.volume = this.props.defaultVolume; |       this.video.volume = volume; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (this.props.autoPlay) { |     if (muted !== undefined) { | ||||||
|  |       this.video.muted = muted; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (autoPlay) { | ||||||
|       this.video.play(); |       this.video.play(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  | @ -413,9 +457,9 @@ class Video extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   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 { 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 playerStyle = {}; | ||||||
| 
 | 
 | ||||||
|     const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth }); |     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; |     let preload; | ||||||
| 
 | 
 | ||||||
|     if (startTime || fullscreen || dragging) { |     if (this.props.currentTime || fullscreen || dragging) { | ||||||
|       preload = 'auto'; |       preload = 'auto'; | ||||||
|     } else if (detailed) { |     } else if (detailed) { | ||||||
|       preload = 'metadata'; |       preload = 'metadata'; | ||||||
|  | @ -532,7 +576,7 @@ class Video extends React.PureComponent { | ||||||
|             </div> |             </div> | ||||||
| 
 | 
 | ||||||
|             <div className='video-player__buttons right'> |             <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>} |               {(!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>} |               {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> |               <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> | ||||||
|  |  | ||||||
|  | @ -38,6 +38,7 @@ import trends from './trends'; | ||||||
| import announcements from './announcements'; | import announcements from './announcements'; | ||||||
| import markers from './markers'; | import markers from './markers'; | ||||||
| import account_notes from './account_notes'; | import account_notes from './account_notes'; | ||||||
|  | import picture_in_picture from './picture_in_picture'; | ||||||
| 
 | 
 | ||||||
| const reducers = { | const reducers = { | ||||||
|   announcements, |   announcements, | ||||||
|  | @ -79,6 +80,7 @@ const reducers = { | ||||||
|   trends, |   trends, | ||||||
|   markers, |   markers, | ||||||
|   account_notes, |   account_notes, | ||||||
|  |   picture_in_picture, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default combineReducers(reducers); | export default combineReducers(reducers); | ||||||
|  |  | ||||||
|  | @ -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; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | @ -144,7 +144,8 @@ | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .icon-button { | .icon-button { | ||||||
|   display: inline-block; |   display: inline-flex; | ||||||
|  |   align-items: center; | ||||||
|   padding: 0; |   padding: 0; | ||||||
|   color: $action-button-color; |   color: $action-button-color; | ||||||
|   border: 0; |   border: 0; | ||||||
|  | @ -226,6 +227,14 @@ | ||||||
|       background: rgba($base-overlay-background, 0.9); |       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 { | .text-icon-button { | ||||||
|  |  | ||||||
|  | @ -564,24 +564,6 @@ | ||||||
|   align-items: center; |   align-items: center; | ||||||
|   display: flex; |   display: flex; | ||||||
|   margin-top: 8px; |   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 { | .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%); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue