diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index decf7279f..7bfd66d3e 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -37,6 +37,7 @@ import { initMuteModal } from '../actions/mutes';
import { initBlockModal } from '../actions/blocks';
import { initReport } from '../actions/reports';
import { openModal } from '../actions/modal';
+import { deployPictureInPicture } from '../actions/picture_in_picture';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { boostModal, deleteModal } from '../initial_state';
import { showAlertForError } from '../actions/alerts';
@@ -56,6 +57,7 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, props) => ({
status: getStatus(state, props),
+ usingPiP: state.get('picture_in_picture').statusId === props.id,
});
return mapStateToProps;
@@ -207,6 +209,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(unblockDomain(domain));
},
+ deployPictureInPicture (status, type, mediaProps) {
+ dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
+ },
+
});
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));
diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js
index 5b8172694..6954d2a4c 100644
--- a/app/javascript/mastodon/features/audio/index.js
+++ b/app/javascript/mastodon/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));
@@ -112,6 +129,10 @@ class Audio extends React.PureComponent {
componentWillUnmount () {
window.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleResize);
+
+ if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
+ this.props.deployPictureInPicture('audio', this._pack());
+ }
}
togglePlay = () => {
@@ -248,7 +269,13 @@ class Audio extends React.PureComponent {
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
if (!this.state.paused && !inView) {
- this.setState({ paused: true }, () => this.audio.pause());
+ this.audio.pause();
+
+ if (this.props.deployPictureInPicture) {
+ this.props.deployPictureInPicture('audio', this._pack());
+ }
+
+ this.setState({ paused: true });
}
}, 150, { trailing: true });
@@ -261,10 +288,22 @@ class Audio extends React.PureComponent {
}
handleLoadedData = () => {
- const { autoPlay } = this.props;
+ const { autoPlay, currentTime, volume, muted } = this.props;
+
+ if (currentTime) {
+ this.audio.currentTime = currentTime;
+ }
+
+ if (volume !== undefined) {
+ this.audio.volume = volume;
+ }
+
+ if (muted !== undefined) {
+ this.audio.muted = muted;
+ }
if (autoPlay) {
- this.audio.play();
+ this.togglePlay();
}
}
@@ -350,7 +389,7 @@ class Audio extends React.PureComponent {
render () {
const { src, intl, alt, editable, autoPlay } = this.props;
const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
- const progress = (currentTime / duration) * 100;
+ const progress = Math.min((currentTime / duration) * 100, 100);
return (
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.js b/app/javascript/mastodon/features/picture_in_picture/components/footer.js
new file mode 100644
index 000000000..086cda954
--- /dev/null
+++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.js
@@ -0,0 +1,137 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'mastodon/components/icon_button';
+import classNames from 'classnames';
+import { me, boostModal } from 'mastodon/initial_state';
+import { defineMessages, injectIntl } from 'react-intl';
+import { replyCompose } from 'mastodon/actions/compose';
+import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions';
+import { makeGetStatus } from 'mastodon/selectors';
+import { openModal } from 'mastodon/actions/modal';
+
+const messages = defineMessages({
+ reply: { id: 'status.reply', defaultMessage: 'Reply' },
+ replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
+ reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+ reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
+ cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+ cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+ favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+ replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+ replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+});
+
+const makeMapStateToProps = () => {
+ const getStatus = makeGetStatus();
+
+ const mapStateToProps = (state, { statusId }) => ({
+ status: getStatus(state, { id: statusId }),
+ askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
+ });
+
+ return mapStateToProps;
+};
+
+export default @connect(makeMapStateToProps)
+@injectIntl
+class Footer extends ImmutablePureComponent {
+
+ static contextTypes = {
+ router: PropTypes.object,
+ };
+
+ static propTypes = {
+ statusId: PropTypes.string.isRequired,
+ status: ImmutablePropTypes.map.isRequired,
+ intl: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ askReplyConfirmation: PropTypes.bool,
+ };
+
+ _performReply = () => {
+ const { dispatch, status } = this.props;
+ dispatch(replyCompose(status, this.context.router.history));
+ };
+
+ handleReplyClick = () => {
+ const { dispatch, askReplyConfirmation, intl } = this.props;
+
+ if (askReplyConfirmation) {
+ dispatch(openModal('CONFIRM', {
+ message: intl.formatMessage(messages.replyMessage),
+ confirm: intl.formatMessage(messages.replyConfirm),
+ onConfirm: this._performReply,
+ }));
+ } else {
+ this._performReply();
+ }
+ };
+
+ handleFavouriteClick = () => {
+ const { dispatch, status } = this.props;
+
+ if (status.get('favourited')) {
+ dispatch(unfavourite(status));
+ } else {
+ dispatch(favourite(status));
+ }
+ };
+
+ _performReblog = () => {
+ const { dispatch, status } = this.props;
+ dispatch(reblog(status));
+ }
+
+ handleReblogClick = e => {
+ const { dispatch, status } = this.props;
+
+ if (status.get('reblogged')) {
+ dispatch(unreblog(status));
+ } else if ((e && e.shiftKey) || !boostModal) {
+ this._performReblog();
+ } else {
+ dispatch(openModal('BOOST', { status, onReblog: this._performReblog }));
+ }
+ };
+
+ render () {
+ const { status, intl } = this.props;
+
+ const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
+ const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
+
+ let replyIcon, replyTitle;
+
+ if (status.get('in_reply_to_id', null) === null) {
+ replyIcon = 'reply';
+ replyTitle = intl.formatMessage(messages.reply);
+ } else {
+ replyIcon = 'reply-all';
+ replyTitle = intl.formatMessage(messages.replyAll);
+ }
+
+ let reblogTitle = '';
+
+ if (status.get('reblogged')) {
+ reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
+ } else if (publicStatus) {
+ reblogTitle = intl.formatMessage(messages.reblog);
+ } else if (reblogPrivate) {
+ reblogTitle = intl.formatMessage(messages.reblog_private);
+ } else {
+ reblogTitle = intl.formatMessage(messages.cannot_reblog);
+ }
+
+ return (
+
+
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/picture_in_picture/components/header.js b/app/javascript/mastodon/features/picture_in_picture/components/header.js
new file mode 100644
index 000000000..4cb6de1a4
--- /dev/null
+++ b/app/javascript/mastodon/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 'mastodon/components/icon_button';
+import { Link } from 'react-router-dom';
+import Avatar from 'mastodon/components/avatar';
+import DisplayName from 'mastodon/components/display_name';
+
+const mapStateToProps = (state, { accountId }) => ({
+ account: state.getIn(['accounts', accountId]),
+});
+
+export default @connect(mapStateToProps)
+class Header extends ImmutablePureComponent {
+
+ static propTypes = {
+ accountId: PropTypes.string.isRequired,
+ statusId: PropTypes.string.isRequired,
+ account: ImmutablePropTypes.map.isRequired,
+ onClose: PropTypes.func.isRequired,
+ };
+
+ render () {
+ const { account, statusId, onClose } = this.props;
+
+ return (
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/picture_in_picture/index.js b/app/javascript/mastodon/features/picture_in_picture/index.js
new file mode 100644
index 000000000..1e59fbcd3
--- /dev/null
+++ b/app/javascript/mastodon/features/picture_in_picture/index.js
@@ -0,0 +1,85 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import Video from 'mastodon/features/video';
+import Audio from 'mastodon/features/audio';
+import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
+import Header from './components/header';
+import Footer from './components/footer';
+
+const mapStateToProps = state => ({
+ ...state.get('picture_in_picture'),
+});
+
+export default @connect(mapStateToProps)
+class PictureInPicture extends React.Component {
+
+ static propTypes = {
+ statusId: PropTypes.string,
+ accountId: PropTypes.string,
+ type: PropTypes.string,
+ src: PropTypes.string,
+ muted: PropTypes.bool,
+ volume: PropTypes.number,
+ currentTime: PropTypes.number,
+ poster: PropTypes.string,
+ backgroundColor: PropTypes.string,
+ foregroundColor: PropTypes.string,
+ accentColor: PropTypes.string,
+ dispatch: PropTypes.func.isRequired,
+ };
+
+ handleClose = () => {
+ const { dispatch } = this.props;
+ dispatch(removePictureInPicture());
+ }
+
+ render () {
+ const { type, src, currentTime, accountId, statusId } = this.props;
+
+ if (!currentTime) {
+ return null;
+ }
+
+ let player;
+
+ if (type === 'video') {
+ player = (
+
+ );
+ } else if (type === 'audio') {
+ player = (
+
+ );
+ }
+
+ return (
+
+
+
+ {player}
+
+
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index b1ae0b2cc..c2b883f7f 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -15,6 +15,7 @@ import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
import AnimatedNumber from 'mastodon/components/animated_number';
+import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
@@ -40,6 +41,7 @@ class DetailedStatus extends ImmutablePureComponent {
domain: PropTypes.string.isRequired,
compact: PropTypes.bool,
showMedia: PropTypes.bool,
+ usingPiP: PropTypes.bool,
onToggleMediaVisibility: PropTypes.func,
};
@@ -100,7 +102,7 @@ class DetailedStatus extends ImmutablePureComponent {
render () {
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
const outerStyle = { boxSizing: 'border-box' };
- const { intl, compact } = this.props;
+ const { intl, compact, usingPiP } = this.props;
if (!status) {
return null;
@@ -116,7 +118,9 @@ class DetailedStatus extends ImmutablePureComponent {
outerStyle.height = `${this.state.height}px`;
}
- if (status.get('media_attachments').size > 0) {
+ if (usingPiP) {
+ media =
;
+ } else if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index 179df53a1..cf3a5fa44 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -143,6 +143,7 @@ const makeMapStateToProps = () => {
descendantsIds,
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
domain: state.getIn(['meta', 'domain']),
+ usingPiP: state.get('picture_in_picture').statusId === props.params.statusId,
};
};
@@ -167,6 +168,7 @@ class Status extends ImmutablePureComponent {
askReplyConfirmation: PropTypes.bool,
multiColumn: PropTypes.bool,
domain: PropTypes.string.isRequired,
+ usingPiP: PropTypes.bool,
};
state = {
@@ -492,7 +494,7 @@ class Status extends ImmutablePureComponent {
render () {
let ancestors, descendants;
- const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn } = this.props;
+ const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, usingPiP } = this.props;
const { fullscreen } = this.state;
if (status === null) {
@@ -550,6 +552,7 @@ class Status extends ImmutablePureComponent {
domain={domain}
showMedia={this.state.showMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility}
+ usingPiP={usingPiP}
/>
+
diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js
index 99dcdca22..54c3baf76 100644
--- a/app/javascript/mastodon/features/video/index.js
+++ b/app/javascript/mastodon/features/video/index.js
@@ -104,20 +104,23 @@ class Video extends React.PureComponent {
width: PropTypes.number,
height: PropTypes.number,
sensitive: PropTypes.bool,
- startTime: PropTypes.number,
+ currentTime: PropTypes.number,
onOpenVideo: PropTypes.func,
onCloseVideo: PropTypes.func,
detailed: PropTypes.bool,
inline: PropTypes.bool,
editable: PropTypes.bool,
+ alwaysVisible: PropTypes.bool,
cacheWidth: PropTypes.func,
visible: PropTypes.bool,
onToggleVisibility: PropTypes.func,
+ deployPictureInPicture: PropTypes.func,
intl: PropTypes.object.isRequired,
blurhash: PropTypes.string,
link: PropTypes.node,
autoPlay: PropTypes.bool,
- defaultVolume: PropTypes.number,
+ volume: PropTypes.number,
+ muted: PropTypes.bool,
};
state = {
@@ -297,6 +300,15 @@ class Video extends React.PureComponent {
document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+
+ if (!this.state.paused && this.video && this.props.deployPictureInPicture) {
+ this.props.deployPictureInPicture('video', {
+ src: this.props.src,
+ currentTime: this.video.currentTime,
+ muted: this.video.muted,
+ volume: this.video.volume,
+ });
+ }
}
componentWillReceiveProps (nextProps) {
@@ -328,7 +340,18 @@ class Video extends React.PureComponent {
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
if (!this.state.paused && !inView) {
- this.setState({ paused: true }, () => this.video.pause());
+ this.video.pause();
+
+ if (this.props.deployPictureInPicture) {
+ this.props.deployPictureInPicture('video', {
+ src: this.props.src,
+ currentTime: this.video.currentTime,
+ muted: this.video.muted,
+ volume: this.video.volume,
+ });
+ }
+
+ this.setState({ paused: true });
}
}, 150, { trailing: true })
@@ -361,15 +384,21 @@ class Video extends React.PureComponent {
}
handleLoadedData = () => {
- if (this.props.startTime) {
- this.video.currentTime = this.props.startTime;
+ const { currentTime, volume, muted, autoPlay } = this.props;
+
+ if (currentTime) {
+ this.video.currentTime = currentTime;
}
- if (this.props.defaultVolume !== undefined) {
- this.video.volume = this.props.defaultVolume;
+ if (volume !== undefined) {
+ this.video.volume = volume;
}
- if (this.props.autoPlay) {
+ if (muted !== undefined) {
+ this.video.muted = muted;
+ }
+
+ if (autoPlay) {
this.video.play();
}
}
@@ -414,9 +443,9 @@ class Video extends React.PureComponent {
}
render () {
- const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, editable, blurhash } = this.props;
+ const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, editable, blurhash } = this.props;
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
- const progress = (currentTime / duration) * 100;
+ const progress = Math.min((currentTime / duration) * 100, 100);
const playerStyle = {};
let { width, height } = this.props;
@@ -430,7 +459,7 @@ class Video extends React.PureComponent {
let preload;
- if (startTime || fullscreen || dragging) {
+ if (this.props.currentTime || fullscreen || dragging) {
preload = 'auto';
} else if (detailed) {
preload = 'metadata';
@@ -530,7 +559,7 @@ class Video extends React.PureComponent {
- {(!onCloseVideo && !editable && !fullscreen) && }
+ {(!onCloseVideo && !editable && !fullscreen && !this.props.alwaysVisible) && }
{(!fullscreen && onOpenVideo) && }
{onCloseVideo && }
diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js
index 3823bb05e..a8fb69c27 100644
--- a/app/javascript/mastodon/reducers/index.js
+++ b/app/javascript/mastodon/reducers/index.js
@@ -36,6 +36,7 @@ import trends from './trends';
import missed_updates from './missed_updates';
import announcements from './announcements';
import markers from './markers';
+import picture_in_picture from './picture_in_picture';
const reducers = {
announcements,
@@ -75,6 +76,7 @@ const reducers = {
trends,
missed_updates,
markers,
+ picture_in_picture,
};
export default combineReducers(reducers);
diff --git a/app/javascript/mastodon/reducers/picture_in_picture.js b/app/javascript/mastodon/reducers/picture_in_picture.js
new file mode 100644
index 000000000..06cd8c5e8
--- /dev/null
+++ b/app/javascript/mastodon/reducers/picture_in_picture.js
@@ -0,0 +1,22 @@
+import { PICTURE_IN_PICTURE_DEPLOY, PICTURE_IN_PICTURE_REMOVE } from 'mastodon/actions/picture_in_picture';
+
+const initialState = {
+ statusId: null,
+ accountId: null,
+ type: null,
+ src: null,
+ muted: false,
+ volume: 0,
+ currentTime: 0,
+};
+
+export default function pictureInPicture(state = initialState, action) {
+ switch(action.type) {
+ case PICTURE_IN_PICTURE_DEPLOY:
+ return { statusId: action.statusId, accountId: action.accountId, type: action.playerType, ...action.props };
+ case PICTURE_IN_PICTURE_REMOVE:
+ return { ...initialState };
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 5e79b4a11..a20265bb9 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -163,7 +163,8 @@
}
.icon-button {
- display: inline-block;
+ display: inline-flex;
+ align-items: center;
padding: 0;
color: $action-button-color;
border: 0;
@@ -245,6 +246,14 @@
background: rgba($base-overlay-background, 0.9);
}
}
+
+ &__counter {
+ display: inline-block;
+ width: 14px;
+ margin-left: 4px;
+ font-size: 12px;
+ font-weight: 500;
+ }
}
.text-icon-button {
@@ -1139,24 +1148,6 @@
align-items: center;
display: flex;
margin-top: 8px;
-
- &__counter {
- display: inline-flex;
- margin-right: 11px;
- align-items: center;
-
- .status__action-bar-button {
- margin-right: 4px;
- }
-
- &__label {
- display: inline-block;
- width: 14px;
- font-size: 12px;
- font-weight: 500;
- color: $action-button-color;
- }
- }
}
.status__action-bar-button {
@@ -7034,3 +7025,100 @@ noscript {
}
}
}
+
+.picture-in-picture {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ width: 300px;
+
+ &__footer {
+ border-radius: 0 0 4px 4px;
+ background: lighten($ui-base-color, 4%);
+ padding: 10px;
+ padding-top: 12px;
+ display: flex;
+ justify-content: space-between;
+ }
+
+ &__header {
+ border-radius: 4px 4px 0 0;
+ background: lighten($ui-base-color, 4%);
+ padding: 10px;
+ display: flex;
+ justify-content: space-between;
+
+ &__account {
+ display: flex;
+ text-decoration: none;
+ }
+
+ .account__avatar {
+ margin-right: 10px;
+ }
+
+ .display-name {
+ color: $primary-text-color;
+ text-decoration: none;
+
+ strong,
+ span {
+ display: block;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ span {
+ color: $darker-text-color;
+ }
+ }
+ }
+
+ .video-player,
+ .audio-player {
+ border-radius: 0;
+ }
+
+ @media screen and (max-width: 415px) {
+ width: 210px;
+ bottom: 10px;
+ right: 10px;
+
+ &__footer {
+ display: none;
+ }
+
+ .video-player,
+ .audio-player {
+ border-radius: 0 0 4px 4px;
+ }
+ }
+}
+
+.picture-in-picture-placeholder {
+ box-sizing: border-box;
+ border: 2px dashed lighten($ui-base-color, 8%);
+ background: $base-shadow-color;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ margin-top: 10px;
+ font-size: 16px;
+ font-weight: 500;
+ cursor: pointer;
+ color: $darker-text-color;
+
+ i {
+ display: block;
+ font-size: 24px;
+ font-weight: 400;
+ margin-bottom: 10px;
+ }
+
+ &:hover,
+ &:focus,
+ &:active {
+ border-color: lighten($ui-base-color, 12%);
+ }
+}