diff --git a/app/javascript/flavours/glitch/actions/picture_in_picture.js b/app/javascript/flavours/glitch/actions/picture_in_picture.js
new file mode 100644
index 0000000000..4085cb59e0
--- /dev/null
+++ b/app/javascript/flavours/glitch/actions/picture_in_picture.js
@@ -0,0 +1,38 @@
+// @ts-check
+
+export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY';
+export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE';
+
+/**
+ * @typedef MediaProps
+ * @property {string} src
+ * @property {boolean} muted
+ * @property {number} volume
+ * @property {number} currentTime
+ * @property {string} poster
+ * @property {string} backgroundColor
+ * @property {string} foregroundColor
+ * @property {string} accentColor
+ */
+
+/**
+ * @param {string} statusId
+ * @param {string} accountId
+ * @param {string} playerType
+ * @param {MediaProps} props
+ * @return {object}
+ */
+export const deployPictureInPicture = (statusId, accountId, playerType, props) => ({
+ type: PICTURE_IN_PICTURE_DEPLOY,
+ statusId,
+ accountId,
+ playerType,
+ props,
+});
+
+/*
+ * @return {object}
+ */
+export const removePictureInPicture = () => ({
+ type: PICTURE_IN_PICTURE_REMOVE,
+});
diff --git a/app/javascript/flavours/glitch/components/animated_number.js b/app/javascript/flavours/glitch/components/animated_number.js
index e3235e368e..3cc5173dd4 100644
--- a/app/javascript/flavours/glitch/components/animated_number.js
+++ b/app/javascript/flavours/glitch/components/animated_number.js
@@ -5,10 +5,21 @@ import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring';
import { reduceMotion } from 'flavours/glitch/util/initial_state';
+const obfuscatedCount = count => {
+ if (count < 0) {
+ return 0;
+ } else if (count <= 1) {
+ return count;
+ } else {
+ return '1+';
+ }
+};
+
export default class AnimatedNumber extends React.PureComponent {
static propTypes = {
value: PropTypes.number.isRequired,
+ obfuscate: PropTypes.bool,
};
state = {
@@ -36,11 +47,11 @@ export default class AnimatedNumber extends React.PureComponent {
}
render () {
- const { value } = this.props;
+ const { value, obfuscate } = this.props;
const { direction } = this.state;
if (reduceMotion) {
- return ;
+ return obfuscate ? obfuscatedCount(value) : ;
}
const styles = [{
@@ -54,7 +65,7 @@ export default class AnimatedNumber extends React.PureComponent {
{items => (
{items.map(({ key, data, style }) => (
- 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>
+ 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : }
))}
)}
diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js
index e134d0a39c..58d3568dd2 100644
--- a/app/javascript/flavours/glitch/components/icon_button.js
+++ b/app/javascript/flavours/glitch/components/icon_button.js
@@ -4,6 +4,7 @@ import spring from 'react-motion/lib/spring';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import Icon from 'flavours/glitch/components/icon';
+import AnimatedNumber from 'flavours/glitch/components/animated_number';
export default class IconButton extends React.PureComponent {
@@ -27,6 +28,8 @@ export default class IconButton extends React.PureComponent {
overlay: PropTypes.bool,
tabIndex: PropTypes.string,
label: PropTypes.string,
+ counter: PropTypes.number,
+ obfuscateCount: PropTypes.bool,
};
static defaultProps = {
@@ -104,6 +107,8 @@ export default class IconButton extends React.PureComponent {
pressed,
tabIndex,
title,
+ counter,
+ obfuscateCount,
} = this.props;
const {
@@ -118,8 +123,13 @@ export default class IconButton extends React.PureComponent {
activate,
deactivate,
overlayed: overlay,
+ 'icon-button--with-counter': typeof counter !== 'undefined',
});
+ if (typeof counter !== 'undefined') {
+ style.width = 'auto';
+ }
+
return (
);
diff --git a/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js
new file mode 100644
index 0000000000..01dce0a383
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/picture_in_picture_placeholder.js
@@ -0,0 +1,69 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Icon from 'flavours/glitch/components/icon';
+import { removePictureInPicture } from 'flavours/glitch/actions/picture_in_picture';
+import { connect } from 'react-redux';
+import { debounce } from 'lodash';
+import { FormattedMessage } from 'react-intl';
+
+export default @connect()
+class PictureInPicturePlaceholder extends React.PureComponent {
+
+ static propTypes = {
+ width: PropTypes.number,
+ dispatch: PropTypes.func.isRequired,
+ };
+
+ state = {
+ width: this.props.width,
+ height: this.props.width && (this.props.width / (16/9)),
+ };
+
+ handleClick = () => {
+ const { dispatch } = this.props;
+ dispatch(removePictureInPicture());
+ }
+
+ setRef = c => {
+ this.node = c;
+
+ if (this.node) {
+ this._setDimensions();
+ }
+ }
+
+ _setDimensions () {
+ const width = this.node.offsetWidth;
+ const height = width / (16/9);
+
+ this.setState({ width, height });
+ }
+
+ componentDidMount () {
+ window.addEventListener('resize', this.handleResize, { passive: true });
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('resize', this.handleResize);
+ }
+
+ handleResize = debounce(() => {
+ if (this.node) {
+ this._setDimensions();
+ }
+ }, 250, {
+ trailing: true,
+ });
+
+ render () {
+ const { height } = this.state;
+
+ return (
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index fc7940e5a0..1b7dce4c4a 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -17,6 +17,7 @@ import classNames from 'classnames';
import { autoUnfoldCW } from 'flavours/glitch/util/content_warning';
import PollContainer from 'flavours/glitch/containers/poll_container';
import { displayMedia } from 'flavours/glitch/util/initial_state';
+import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
// We use the component (and not the container) since we do not want
// to use the progress bar to show download progress
@@ -97,6 +98,8 @@ class Status extends ImmutablePureComponent {
cachedMediaWidth: PropTypes.number,
onClick: PropTypes.func,
scrollKey: PropTypes.string,
+ deployPictureInPicture: PropTypes.func,
+ usingPiP: PropTypes.bool,
};
state = {
@@ -123,6 +126,7 @@ class Status extends ImmutablePureComponent {
'hidden',
'expanded',
'unread',
+ 'usingPiP',
]
updateOnStates = [
@@ -394,6 +398,12 @@ class Status extends ImmutablePureComponent {
}
}
+ handleDeployPictureInPicture = (type, mediaProps) => {
+ const { deployPictureInPicture, status } = this.props;
+
+ deployPictureInPicture(status, type, mediaProps);
+ }
+
handleHotkeyReply = e => {
e.preventDefault();
this.props.onReply(this.props.status, this.context.router.history);
@@ -496,6 +506,7 @@ class Status extends ImmutablePureComponent {
hidden,
unread,
featured,
+ usingPiP,
...other
} = this.props;
const { isExpanded, isCollapsed, forceFilter } = this.state;
@@ -576,6 +587,9 @@ class Status extends ImmutablePureComponent {
if (status.get('poll')) {
media = ;
mediaIcon = 'tasks';
+ } else if (usingPiP) {
+ media = ;
+ mediaIcon = 'video-camera';
} else if (attachments.size > 0) {
if (muted || attachments.some(item => item.get('type') === 'unknown')) {
media = (
@@ -601,6 +615,7 @@ class Status extends ImmutablePureComponent {
width={this.props.cachedMediaWidth}
height={110}
cacheWidth={this.props.cacheMediaWidth}
+ deployPictureInPicture={this.handleDeployPictureInPicture}
/>
)}
@@ -624,6 +639,7 @@ class Status extends ImmutablePureComponent {
onOpenVideo={this.handleOpenVideo}
width={this.props.cachedMediaWidth}
cacheWidth={this.props.cacheMediaWidth}
+ deployPictureInPicture={this.handleDeployPictureInPicture}
visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility}
/>)}
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index cfb03c21ba..2ccb02c624 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -40,16 +40,6 @@ const messages = defineMessages({
hide: { id: 'status.hide', defaultMessage: 'Hide toot' },
});
-const obfuscatedCount = count => {
- if (count < 0) {
- return 0;
- } else if (count <= 1) {
- return count;
- } else {
- return '1+';
- }
-};
-
export default @injectIntl
class StatusActionBar extends ImmutablePureComponent {
@@ -284,10 +274,14 @@ class StatusActionBar extends ImmutablePureComponent {
);
if (showReplyCount) {
replyButton = (
-
- {replyButton}
- {obfuscatedCount(status.get('replies_count'))}
-
+
);
}
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js
index 2cbe3d094e..ac423c58db 100644
--- a/app/javascript/flavours/glitch/containers/status_container.js
+++ b/app/javascript/flavours/glitch/containers/status_container.js
@@ -22,6 +22,7 @@ import { initMuteModal } from 'flavours/glitch/actions/mutes';
import { initBlockModal } from 'flavours/glitch/actions/blocks';
import { initReport } from 'flavours/glitch/actions/reports';
import { openModal } from 'flavours/glitch/actions/modal';
+import { deployPictureInPicture } from 'flavours/glitch/actions/picture_in_picture';
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state';
@@ -69,6 +70,7 @@ const makeMapStateToProps = () => {
account : account || props.account,
settings : state.get('local_settings'),
prepend : prepend || props.prepend,
+ usingPiP : state.get('picture_in_picture').statusId === props.id,
};
};
@@ -245,6 +247,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
}
},
+ deployPictureInPicture (status, type, mediaProps) {
+ dispatch((_, getState) => {
+ if (getState().getIn(['local_settings', 'media', 'pop_in_player'])) {
+ dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
+ }
+ });
+ },
+
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/flavours/glitch/features/audio/index.js b/app/javascript/flavours/glitch/features/audio/index.js
index 7a2fb7fb6c..6d09ac8d2f 100644
--- a/app/javascript/flavours/glitch/features/audio/index.js
+++ b/app/javascript/flavours/glitch/features/audio/index.js
@@ -37,7 +37,11 @@ class Audio extends React.PureComponent {
backgroundColor: PropTypes.string,
foregroundColor: PropTypes.string,
accentColor: PropTypes.string,
+ currentTime: PropTypes.number,
autoPlay: PropTypes.bool,
+ volume: PropTypes.number,
+ muted: PropTypes.bool,
+ deployPictureInPicture: PropTypes.func,
};
state = {
@@ -64,6 +68,19 @@ class Audio extends React.PureComponent {
}
}
+ _pack() {
+ return {
+ src: this.props.src,
+ volume: this.audio.volume,
+ muted: this.audio.muted,
+ currentTime: this.audio.currentTime,
+ poster: this.props.poster,
+ backgroundColor: this.props.backgroundColor,
+ foregroundColor: this.props.foregroundColor,
+ accentColor: this.props.accentColor,
+ };
+ }
+
_setDimensions () {
const width = this.player.offsetWidth;
const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
@@ -100,6 +117,7 @@ class Audio extends React.PureComponent {
}
componentDidMount () {
+ window.addEventListener('scroll', this.handleScroll);
window.addEventListener('resize', this.handleResize, { passive: true });
}
@@ -115,7 +133,12 @@ class Audio extends React.PureComponent {
}
componentWillUnmount () {
+ window.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleResize);
+
+ if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
+ this.props.deployPictureInPicture('audio', this._pack());
+ }
}
togglePlay = () => {
@@ -243,6 +266,25 @@ class Audio extends React.PureComponent {
}
}, 15);
+ handleScroll = throttle(() => {
+ if (!this.canvas || !this.audio) {
+ return;
+ }
+
+ const { top, height } = this.canvas.getBoundingClientRect();
+ const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
+
+ if (!this.state.paused && !inView) {
+ this.audio.pause();
+
+ if (this.props.deployPictureInPicture) {
+ this.props.deployPictureInPicture('audio', this._pack());
+ }
+
+ this.setState({ paused: true });
+ }
+ }, 150, { trailing: true });
+
handleMouseEnter = () => {
this.setState({ hovered: true });
}
@@ -252,10 +294,22 @@ class Audio extends React.PureComponent {
}
handleLoadedData = () => {
- const { autoPlay } = this.props;
+ const { autoPlay, currentTime, volume, muted } = this.props;
+
+ if (currentTime) {
+ this.audio.currentTime = currentTime;
+ }
+
+ if (volume !== undefined) {
+ this.audio.volume = volume;
+ }
+
+ if (muted !== undefined) {
+ this.audio.muted = muted;
+ }
if (autoPlay) {
- this.audio.play();
+ this.togglePlay();
}
}
@@ -341,7 +395,7 @@ class Audio extends React.PureComponent {
render () {
const { src, intl, alt, editable, autoPlay } = this.props;
const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
- const progress = (currentTime / duration) * 100;
+ const progress = Math.min((currentTime / duration) * 100, 100);
return (
diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js
index 0b34280275..45d10d154e 100644
--- a/app/javascript/flavours/glitch/features/local_settings/page/index.js
+++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js
@@ -28,6 +28,8 @@ const messages = defineMessages({
rewrite_mentions_no: { id: 'settings.rewrite_mentions_no', defaultMessage: 'Do not rewrite mentions' },
rewrite_mentions_acct: { id: 'settings.rewrite_mentions_acct', defaultMessage: 'Rewrite with username and domain (when the account is remote)' },
rewrite_mentions_username: { id: 'settings.rewrite_mentions_username', defaultMessage: 'Rewrite with username' },
+ pop_in_left: { id: 'settings.pop_in_left', defaultMessage: 'Left' },
+ pop_in_right: { id: 'settings.pop_in_right', defaultMessage: 'Right' },
});
export default @injectIntl
@@ -384,7 +386,7 @@ class LocalSettingsPage extends React.PureComponent {
),
- ({ onChange, settings }) => (
+ ({ intl, onChange, settings }) => (
+
+
+
+
+
+
),
];
diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js
new file mode 100644
index 0000000000..2ddba140e2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js
@@ -0,0 +1,162 @@
+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,
+ showReplyCount: state.getIn(['local_settings', 'show_reply_count']),
+ });
+
+ 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,
+ showReplyCount: 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, showReplyCount } = 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);
+ }
+
+ let replyButton = null;
+ if (showReplyCount) {
+ replyButton = (
+
+ );
+ } else {
+ replyButton = (
+
+ );
+ }
+
+ return (
+
+ {replyButton}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js
new file mode 100644
index 0000000000..24adcde257
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/header.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'flavours/glitch/components/icon_button';
+import { Link } from 'react-router-dom';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+
+const mapStateToProps = (state, { accountId }) => ({
+ account: state.getIn(['accounts', accountId]),
+});
+
+export default @connect(mapStateToProps)
+class Header extends ImmutablePureComponent {
+
+ static propTypes = {
+ accountId: PropTypes.string.isRequired,
+ statusId: PropTypes.string.isRequired,
+ account: ImmutablePropTypes.map.isRequired,
+ onClose: PropTypes.func.isRequired,
+ };
+
+ render () {
+ const { account, statusId, onClose } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/index.js b/app/javascript/flavours/glitch/features/picture_in_picture/index.js
new file mode 100644
index 0000000000..3e6a20faa8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/picture_in_picture/index.js
@@ -0,0 +1,88 @@
+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';
+import classNames from 'classnames';
+
+const mapStateToProps = state => ({
+ ...state.get('picture_in_picture'),
+ left: state.getIn(['local_settings', 'media', 'pop_in_position']) === 'left',
+});
+
+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,
+ left: PropTypes.bool,
+ };
+
+ handleClose = () => {
+ const { dispatch } = this.props;
+ dispatch(removePictureInPicture());
+ }
+
+ render () {
+ const { type, src, currentTime, accountId, statusId, left } = this.props;
+
+ if (!currentTime) {
+ return null;
+ }
+
+ let player;
+
+ if (type === 'video') {
+ player = (
+
+ );
+ } else if (type === 'audio') {
+ player = (
+
+ );
+ }
+
+ return (
+
+
+
+ {player}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index e4aecbf94d..04d350bcbe 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -18,6 +18,7 @@ import classNames from 'classnames';
import PollContainer from 'flavours/glitch/containers/poll_container';
import Icon from 'flavours/glitch/components/icon';
import AnimatedNumber from 'flavours/glitch/components/animated_number';
+import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
export default class DetailedStatus extends ImmutablePureComponent {
@@ -37,6 +38,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
domain: PropTypes.string.isRequired,
compact: PropTypes.bool,
showMedia: PropTypes.bool,
+ usingPiP: PropTypes.bool,
onToggleMediaVisibility: PropTypes.func,
};
@@ -109,7 +111,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
render () {
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
- const { expanded, onToggleHidden, settings } = this.props;
+ const { expanded, onToggleHidden, settings, usingPiP } = this.props;
const outerStyle = { boxSizing: 'border-box' };
const { compact } = this.props;
@@ -131,6 +133,9 @@ export default class DetailedStatus extends ImmutablePureComponent {
if (status.get('poll')) {
media = ;
mediaIcon = 'tasks';
+ } else if (usingPiP) {
+ media = ;
+ mediaIcon = 'video-camera';
} else if (status.get('media_attachments').size > 0) {
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
media = ;
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index 3e2e95f351..b330adf3ff 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -132,6 +132,7 @@ const makeMapStateToProps = () => {
settings: state.get('local_settings'),
askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0,
domain: state.getIn(['meta', 'domain']),
+ usingPiP: state.get('picture_in_picture').statusId === props.params.statusId,
};
};
@@ -157,6 +158,7 @@ class Status extends ImmutablePureComponent {
askReplyConfirmation: PropTypes.bool,
multiColumn: PropTypes.bool,
domain: PropTypes.string.isRequired,
+ usingPiP: PropTypes.bool,
};
state = {
@@ -514,7 +516,7 @@ class Status extends ImmutablePureComponent {
render () {
let ancestors, descendants;
const { setExpansion } = this;
- const { status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn } = this.props;
+ const { status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, usingPiP } = this.props;
const { fullscreen, isExpanded } = this.state;
if (status === null) {
@@ -578,6 +580,7 @@ class Status extends ImmutablePureComponent {
domain={domain}
showMedia={this.state.showMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility}
+ usingPiP={usingPiP}
/>
+
diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js
index cc60a0d2eb..95bee1331c 100644
--- a/app/javascript/flavours/glitch/features/video/index.js
+++ b/app/javascript/flavours/glitch/features/video/index.js
@@ -103,7 +103,7 @@ class Video extends React.PureComponent {
width: PropTypes.number,
height: PropTypes.number,
sensitive: PropTypes.bool,
- startTime: PropTypes.number,
+ currentTime: PropTypes.number,
onOpenVideo: PropTypes.func,
onCloseVideo: PropTypes.func,
letterbox: PropTypes.bool,
@@ -111,15 +111,18 @@ class Video extends React.PureComponent {
detailed: PropTypes.bool,
inline: PropTypes.bool,
editable: PropTypes.bool,
+ alwaysVisible: PropTypes.bool,
cacheWidth: PropTypes.func,
intl: PropTypes.object.isRequired,
visible: PropTypes.bool,
onToggleVisibility: PropTypes.func,
+ deployPictureInPicture: PropTypes.func,
preventPlayback: PropTypes.bool,
blurhash: PropTypes.string,
link: PropTypes.node,
autoPlay: PropTypes.bool,
- defaultVolume: PropTypes.number,
+ volume: PropTypes.number,
+ muted: PropTypes.bool,
};
state = {
@@ -298,16 +301,27 @@ class Video extends React.PureComponent {
document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+ window.addEventListener('scroll', this.handleScroll);
window.addEventListener('resize', this.handleResize, { passive: true });
}
componentWillUnmount () {
+ window.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleResize);
document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+
+ if (!this.state.paused && this.video && this.props.deployPictureInPicture) {
+ this.props.deployPictureInPicture('video', {
+ src: this.props.src,
+ currentTime: this.video.currentTime,
+ muted: this.video.muted,
+ volume: this.video.volume,
+ });
+ }
}
componentDidUpdate (prevProps) {
@@ -330,6 +344,30 @@ class Video extends React.PureComponent {
trailing: true,
});
+ handleScroll = throttle(() => {
+ if (!this.video) {
+ return;
+ }
+
+ const { top, height } = this.video.getBoundingClientRect();
+ const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
+
+ if (!this.state.paused && !inView) {
+ this.video.pause();
+
+ if (this.props.deployPictureInPicture) {
+ this.props.deployPictureInPicture('video', {
+ src: this.props.src,
+ currentTime: this.video.currentTime,
+ muted: this.video.muted,
+ volume: this.video.volume,
+ });
+ }
+
+ this.setState({ paused: true });
+ }
+ }, 150, { trailing: true })
+
handleFullscreenChange = () => {
this.setState({ fullscreen: isFullscreen() });
}
@@ -360,15 +398,21 @@ class Video extends React.PureComponent {
}
handleLoadedData = () => {
- if (this.props.startTime) {
- this.video.currentTime = this.props.startTime;
+ const { currentTime, volume, muted, autoPlay } = this.props;
+
+ if (currentTime) {
+ this.video.currentTime = currentTime;
+ }
+
+ if (volume !== undefined) {
+ this.video.volume = volume;
}
- if (this.props.defaultVolume !== undefined) {
- this.video.volume = this.props.defaultVolume;
+ if (muted !== undefined) {
+ this.video.muted = muted;
}
- if (this.props.autoPlay) {
+ if (autoPlay) {
this.video.play();
}
}
@@ -413,9 +457,9 @@ class Video extends React.PureComponent {
}
render () {
- const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link, editable, blurhash } = this.props;
+ const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link, editable, blurhash } = this.props;
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
- const progress = (currentTime / duration) * 100;
+ const progress = Math.min((currentTime / duration) * 100, 100);
const playerStyle = {};
const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth });
@@ -440,7 +484,7 @@ class Video extends React.PureComponent {
let preload;
- if (startTime || fullscreen || dragging) {
+ if (this.props.currentTime || fullscreen || dragging) {
preload = 'auto';
} else if (detailed) {
preload = 'metadata';
@@ -532,7 +576,7 @@ class Video extends React.PureComponent {
- {(!onCloseVideo && !editable && !fullscreen) && }
+ {(!onCloseVideo && !editable && !fullscreen && !this.props.alwaysVisible) && }
{(!fullscreen && onOpenVideo) && }
{onCloseVideo && }
diff --git a/app/javascript/flavours/glitch/reducers/index.js b/app/javascript/flavours/glitch/reducers/index.js
index cadbd01a3b..b1ddb769e5 100644
--- a/app/javascript/flavours/glitch/reducers/index.js
+++ b/app/javascript/flavours/glitch/reducers/index.js
@@ -38,6 +38,7 @@ import trends from './trends';
import announcements from './announcements';
import markers from './markers';
import account_notes from './account_notes';
+import picture_in_picture from './picture_in_picture';
const reducers = {
announcements,
@@ -79,6 +80,7 @@ const reducers = {
trends,
markers,
account_notes,
+ picture_in_picture,
};
export default combineReducers(reducers);
diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js
index 3d94d665c2..c115cad6ba 100644
--- a/app/javascript/flavours/glitch/reducers/local_settings.js
+++ b/app/javascript/flavours/glitch/reducers/local_settings.js
@@ -49,6 +49,8 @@ const initialState = ImmutableMap({
letterbox : true,
fullwidth : true,
reveal_behind_cw : false,
+ pop_in_player : true,
+ pop_in_position : 'right',
}),
notifications : ImmutableMap({
favicon_badge : false,
diff --git a/app/javascript/flavours/glitch/reducers/picture_in_picture.js b/app/javascript/flavours/glitch/reducers/picture_in_picture.js
new file mode 100644
index 0000000000..f552a59c21
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/picture_in_picture.js
@@ -0,0 +1,22 @@
+import { PICTURE_IN_PICTURE_DEPLOY, PICTURE_IN_PICTURE_REMOVE } from 'flavours/glitch/actions/picture_in_picture';
+
+const initialState = {
+ statusId: null,
+ accountId: null,
+ type: null,
+ src: null,
+ muted: false,
+ volume: 0,
+ currentTime: 0,
+};
+
+export default function pictureInPicture(state = initialState, action) {
+ switch(action.type) {
+ case PICTURE_IN_PICTURE_DEPLOY:
+ return { statusId: action.statusId, accountId: action.accountId, type: action.playerType, ...action.props };
+ case PICTURE_IN_PICTURE_REMOVE:
+ return { ...initialState };
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 56d658d970..0614278e2d 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -153,6 +153,7 @@
cursor: pointer;
transition: all 100ms ease-in;
transition-property: background-color, color;
+ text-decoration: none;
&:hover,
&:active,
@@ -226,6 +227,20 @@
background: rgba($base-overlay-background, 0.9);
}
}
+
+ &--with-counter {
+ display: inline-flex;
+ align-items: center;
+ width: auto !important;
+ }
+
+ &__counter {
+ display: inline-block;
+ width: 14px;
+ margin-left: 4px;
+ font-size: 12px;
+ font-weight: 500;
+ }
}
.text-icon-button {
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index ba75e3ffec..554ea8cd52 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -564,28 +564,14 @@
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 {
margin-right: 18px;
+
+ &.icon-button--with-counter {
+ margin-right: 14px;
+ }
}
.status__action-bar-dropdown {
@@ -1073,3 +1059,105 @@ a.status-card.compact:hover {
}
}
}
+
+.picture-in-picture {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ width: 300px;
+
+ &.left {
+ right: unset;
+ left: 20px;
+ }
+
+ &__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%);
+ }
+}