Add gif auto-play/pause preference

This introduces a new per-user preference called
"Auto-play animated GIFs", which is enabled by default. When a
user disables this setting, gifs in toots become click-to-play.

Previews of animated gifs were changed to display the video play
button so that users can distinguish them from regular images.

This setting also affects account avatars in the detailed account
view, which was changed to use the same hover-to-play mechanism
that is used for animated avatars in timelines.

Fixes #1652
This commit is contained in:
Patrick Figel
2017-04-17 12:14:03 +02:00
parent 1955a3f444
commit ffb99325ca
12 changed files with 65 additions and 28 deletions

View File

@ -78,7 +78,8 @@ const Item = React.createClass({
attachment: ImmutablePropTypes.map.isRequired,
index: React.PropTypes.number.isRequired,
size: React.PropTypes.number.isRequired,
onClick: React.PropTypes.func.isRequired
onClick: React.PropTypes.func.isRequired,
autoPlayGif: React.PropTypes.bool.isRequired
},
mixins: [PureRenderMixin],
@ -158,16 +159,24 @@ const Item = React.createClass({
/>
);
} else if (attachment.get('type') === 'gifv') {
thumbnail = (
<video
src={attachment.get('url')}
onClick={this.handleClick}
autoPlay={!isIOS()}
loop={true}
muted={true}
style={gifvThumbStyle}
/>
);
if (isIOS() || !this.props.autoPlayGif) {
return (
<div key={attachment.get('id')} style={{ ...itemStyle, background: `url(${attachment.get('preview_url')}) no-repeat center`, left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }} onClick={this.handleClick}>
<div style={{ position: 'absolute', top: '50%', left: '50%', fontSize: '36px', transform: 'translate(-50%, -50%)', padding: '5px', borderRadius: '100px', color: 'rgba(255, 255, 255, 0.8)' }}><i className='fa fa-play' /></div>
</div>
);
} else {
thumbnail = (
<video
src={attachment.get('url')}
onClick={this.handleClick}
autoPlay
loop={true}
muted={true}
style={gifvThumbStyle}
/>
);
}
}
return (
@ -192,7 +201,8 @@ const MediaGallery = React.createClass({
media: ImmutablePropTypes.list.isRequired,
height: React.PropTypes.number.isRequired,
onOpenMedia: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired
intl: React.PropTypes.object.isRequired,
autoPlayGif: React.PropTypes.bool.isRequired
},
mixins: [PureRenderMixin],
@ -227,7 +237,7 @@ const MediaGallery = React.createClass({
);
} else {
const size = media.take(4).size;
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />);
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} autoPlayGif={this.props.autoPlayGif} index={i} size={size} />);
}
return (

View File

@ -29,6 +29,7 @@ const Status = React.createClass({
onBlock: React.PropTypes.func,
me: React.PropTypes.number,
boostModal: React.PropTypes.bool,
autoPlayGif: React.PropTypes.bool,
muted: React.PropTypes.bool
},
@ -79,7 +80,7 @@ const Status = React.createClass({
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
media = <VideoPlayer media={status.getIn(['media_attachments', 0])} sensitive={status.get('sensitive')} onOpenVideo={this.props.onOpenVideo} />;
} else {
media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} />;
media = <MediaGallery media={status.get('media_attachments')} sensitive={status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />;
}
}

View File

@ -27,7 +27,8 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, props) => ({
status: getStatus(state, props.id),
me: state.getIn(['meta', 'me']),
boostModal: state.getIn(['meta', 'boost_modal'])
boostModal: state.getIn(['meta', 'boost_modal']),
autoPlayGif: state.getIn(['meta', 'auto_play_gif'])
});
return mapStateToProps;

View File

@ -5,6 +5,7 @@ import escapeTextContentForBrowser from 'escape-html';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import IconButton from '../../../components/icon_button';
import { Motion, spring } from 'react-motion';
import { connect } from 'react-redux';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@ -12,10 +13,19 @@ const messages = defineMessages({
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }
});
const makeMapStateToProps = () => {
const mapStateToProps = (state, props) => ({
autoPlayGif: state.getIn(['meta', 'auto_play_gif'])
});
return mapStateToProps;
};
const Avatar = React.createClass({
propTypes: {
account: ImmutablePropTypes.map.isRequired
account: ImmutablePropTypes.map.isRequired,
autoPlayGif: React.PropTypes.bool.isRequired
},
getInitialState () {
@ -37,7 +47,7 @@ const Avatar = React.createClass({
},
render () {
const { account } = this.props;
const { account, autoPlayGif } = this.props;
const { isHovered } = this.state;
return (
@ -53,7 +63,7 @@ const Avatar = React.createClass({
onMouseOut={this.handleMouseOut}
onFocus={this.handleMouseOver}
onBlur={this.handleMouseOut}>
<img src={account.get('avatar')} alt={account.get('acct')} style={{ display: 'block', width: '90px', height: '90px' }} />
<img src={autoPlayGif || isHovered ? account.get('avatar') : account.get('avatar_static')} alt={account.get('acct')} style={{ display: 'block', width: '90px', height: '90px' }} />
</a>
}
</Motion>
@ -68,7 +78,8 @@ const Header = React.createClass({
account: ImmutablePropTypes.map,
me: React.PropTypes.number.isRequired,
onFollow: React.PropTypes.func.isRequired,
intl: React.PropTypes.object.isRequired
intl: React.PropTypes.object.isRequired,
autoPlayGif: React.PropTypes.bool.isRequired
},
mixins: [PureRenderMixin],
@ -119,7 +130,7 @@ const Header = React.createClass({
return (
<div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
<div style={{ padding: '20px 10px' }}>
<Avatar account={account} />
<Avatar account={account} autoPlayGif={this.props.autoPlayGif} />
<span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
<span className='account__header__username' style={{ fontSize: '14px', fontWeight: '400', display: 'block', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
@ -134,4 +145,4 @@ const Header = React.createClass({
});
export default injectIntl(Header);
export default connect(makeMapStateToProps)(injectIntl(Header));

View File

@ -19,6 +19,7 @@ const DetailedStatus = React.createClass({
status: ImmutablePropTypes.map.isRequired,
onOpenMedia: React.PropTypes.func.isRequired,
onOpenVideo: React.PropTypes.func.isRequired,
autoPlayGif: React.PropTypes.bool,
},
mixins: [PureRenderMixin],
@ -42,7 +43,7 @@ const DetailedStatus = React.createClass({
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} onOpenVideo={this.props.onOpenVideo} autoplay />;
} else {
media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} />;
media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />;
}
} else {
media = <CardContainer statusId={status.get('id')} />;

View File

@ -39,7 +39,8 @@ const makeMapStateToProps = () => {
ancestorsIds: state.getIn(['timelines', 'ancestors', Number(props.params.statusId)]),
descendantsIds: state.getIn(['timelines', 'descendants', Number(props.params.statusId)]),
me: state.getIn(['meta', 'me']),
boostModal: state.getIn(['meta', 'boost_modal'])
boostModal: state.getIn(['meta', 'boost_modal']),
autoPlayGif: state.getIn(['meta', 'auto_play_gif'])
});
return mapStateToProps;
@ -57,7 +58,8 @@ const Status = React.createClass({
ancestorsIds: ImmutablePropTypes.list,
descendantsIds: ImmutablePropTypes.list,
me: React.PropTypes.number,
boostModal: React.PropTypes.bool
boostModal: React.PropTypes.bool,
autoPlayGif: React.PropTypes.bool
},
mixins: [PureRenderMixin],
@ -126,7 +128,7 @@ const Status = React.createClass({
render () {
let ancestors, descendants;
const { status, ancestorsIds, descendantsIds, me } = this.props;
const { status, ancestorsIds, descendantsIds, me, autoPlayGif } = this.props;
if (status === null) {
return (
@ -155,7 +157,7 @@ const Status = React.createClass({
<div className='scrollable'>
{ancestors}
<DetailedStatus status={status} me={me} onOpenVideo={this.handleOpenVideo} onOpenMedia={this.handleOpenMedia} />
<DetailedStatus status={status} autoPlayGif={autoPlayGif} me={me} onOpenVideo={this.handleOpenVideo} onOpenMedia={this.handleOpenMedia} />
<ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} onReport={this.handleReport} />
{descendants}