New: Write metadata to tags, with UI for previewing changes (#633)

This commit is contained in:
ta264 2019-03-15 12:10:45 +00:00 committed by GitHub
parent 6548f4b1b7
commit 072f772dc8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
82 changed files with 2938 additions and 358 deletions

View file

@ -0,0 +1,34 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import RetagPreviewModalContentConnector from './RetagPreviewModalContentConnector';
function RetagPreviewModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
{
isOpen &&
<RetagPreviewModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
}
</Modal>
);
}
RetagPreviewModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default RetagPreviewModal;

View file

@ -0,0 +1,39 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { clearRetagPreview } from 'Store/Actions/retagPreviewActions';
import RetagPreviewModal from './RetagPreviewModal';
const mapDispatchToProps = {
clearRetagPreview
};
class RetagPreviewModalConnector extends Component {
//
// Listeners
onModalClose = () => {
this.props.clearRetagPreview();
this.props.onModalClose();
}
//
// Render
render() {
return (
<RetagPreviewModal
{...this.props}
onModalClose={this.onModalClose}
/>
);
}
}
RetagPreviewModalConnector.propTypes = {
clearRetagPreview: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(undefined, mapDispatchToProps)(RetagPreviewModalConnector);

View file

@ -0,0 +1,24 @@
.path {
margin-left: 5px;
font-weight: bold;
}
.trackFormat {
margin-left: 5px;
font-family: $monoSpaceFontFamily;
}
.previews {
margin-top: 10px;
}
.selectAllInputContainer {
margin-right: auto;
line-height: 30px;
}
.selectAllInput {
composes: input from '~Components/Form/CheckInput.css';
margin: 0;
}

View file

@ -0,0 +1,186 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import { kinds } from 'Helpers/Props';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalContent from 'Components/Modal/ModalContent';
import ModalHeader from 'Components/Modal/ModalHeader';
import ModalBody from 'Components/Modal/ModalBody';
import ModalFooter from 'Components/Modal/ModalFooter';
import CheckInput from 'Components/Form/CheckInput';
import RetagPreviewRow from './RetagPreviewRow';
import styles from './RetagPreviewModalContent.css';
function getValue(allSelected, allUnselected) {
if (allSelected) {
return true;
} else if (allUnselected) {
return false;
}
return null;
}
class RetagPreviewModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {}
};
}
//
// Control
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState);
}
//
// Listeners
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
}
onSelectedChange = ({ id, value, shiftKey = false }) => {
this.setState((state) => {
return toggleSelected(state, this.props.items, id, value, shiftKey);
});
}
onRetagPress = () => {
this.props.onRetagPress(this.getSelectedIds());
}
//
// Render
render() {
const {
isFetching,
isPopulated,
error,
items,
path,
onModalClose
} = this.props;
const {
allSelected,
allUnselected,
selectedState
} = this.state;
const selectAllValue = getValue(allSelected, allUnselected);
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Write Metadata Tags
</ModalHeader>
<ModalBody>
{
isFetching &&
<LoadingIndicator />
}
{
!isFetching && error &&
<div>Error loading previews</div>
}
{
!isFetching && ((isPopulated && !items.length)) &&
<div>Success! My work is done, no files to retag.</div>
}
{
!isFetching && isPopulated && !!items.length &&
<div>
<Alert>
<div>
All paths are relative to:
<span className={styles.path}>
{path}
</span>
</div>
<div>
MusicBrainz identifiers will also be added to the files; these are not shown below.
</div>
</Alert>
<div className={styles.previews}>
{
items.map((item) => {
return (
<RetagPreviewRow
key={item.trackFileId}
id={item.trackFileId}
path={item.relativePath}
changes={item.changes}
isSelected={selectedState[item.trackFileId]}
onSelectedChange={this.onSelectedChange}
/>
);
})
}
</div>
</div>
}
</ModalBody>
<ModalFooter>
{
isPopulated && !!items.length &&
<CheckInput
className={styles.selectAllInput}
containerClassName={styles.selectAllInputContainer}
name="selectAll"
value={selectAllValue}
onChange={this.onSelectAllChange}
/>
}
<Button
onPress={onModalClose}
>
Cancel
</Button>
<Button
kind={kinds.PRIMARY}
onPress={this.onRetagPress}
>
Retag
</Button>
</ModalFooter>
</ModalContent>
);
}
}
RetagPreviewModalContent.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
path: PropTypes.string.isRequired,
onRetagPress: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default RetagPreviewModalContent;

View file

@ -0,0 +1,86 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createArtistSelector from 'Store/Selectors/createArtistSelector';
import { fetchRetagPreview } from 'Store/Actions/retagPreviewActions';
import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
import RetagPreviewModalContent from './RetagPreviewModalContent';
function createMapStateToProps() {
return createSelector(
(state) => state.retagPreview,
createArtistSelector(),
(retagPreview, artist) => {
const props = { ...retagPreview };
props.isFetching = retagPreview.isFetching;
props.isPopulated = retagPreview.isPopulated;
props.error = retagPreview.error;
props.path = artist.path;
return props;
}
);
}
const mapDispatchToProps = {
fetchRetagPreview,
executeCommand
};
class RetagPreviewModalContentConnector extends Component {
//
// Lifecycle
componentDidMount() {
const {
artistId,
albumId
} = this.props;
this.props.fetchRetagPreview({
artistId,
albumId
});
}
//
// Listeners
onRetagPress = (files) => {
this.props.executeCommand({
name: commandNames.RETAG_FILES,
artistId: this.props.artistId,
files
});
this.props.onModalClose();
}
//
// Render
render() {
return (
<RetagPreviewModalContent
{...this.props}
onRetagPress={this.onRetagPress}
/>
);
}
}
RetagPreviewModalContentConnector.propTypes = {
artistId: PropTypes.number.isRequired,
albumId: PropTypes.number,
retagTracks: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
fetchRetagPreview: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(RetagPreviewModalContentConnector);

View file

@ -0,0 +1,26 @@
.row {
display: flex;
margin-bottom: 5px;
padding: 5px 0;
border-bottom: 1px solid $borderColor;
&:last-of-type {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
}
.column {
display: flex;
flex-direction: column;
}
.selectedContainer {
margin-right: 30px;
}
.path {
margin-left: 10px;
font-weight: bold;
}

View file

@ -0,0 +1,101 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon';
import CheckInput from 'Components/Form/CheckInput';
import styles from './RetagPreviewRow.css';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
function formatMissing(value) {
if (value === undefined || value === 0 || value === '0') {
return (<Icon name={icons.BAN} size={12} />);
}
return value;
}
function formatChange(oldValue, newValue) {
return (
<div> {formatMissing(oldValue)} <Icon name={icons.ARROW_RIGHT_NO_CIRCLE} size={12} /> {formatMissing(newValue)} </div>
);
}
class RetagPreviewRow extends Component {
//
// Lifecycle
componentDidMount() {
const {
id,
onSelectedChange
} = this.props;
onSelectedChange({ id, value: true });
}
//
// Listeners
onSelectedChange = ({ value, shiftKey }) => {
const {
id,
onSelectedChange
} = this.props;
onSelectedChange({ id, value, shiftKey });
}
//
// Render
render() {
const {
id,
path,
changes,
isSelected
} = this.props;
return (
<div className={styles.row}>
<CheckInput
containerClassName={styles.selectedContainer}
name={id.toString()}
value={isSelected}
onChange={this.onSelectedChange}
/>
<div className={styles.column}>
<span className={styles.path}>
{path}
</span>
<DescriptionList>
{
changes.map(({ field, oldValue, newValue }) => {
return (
<DescriptionListItem
key={field}
title={field}
data={formatChange(oldValue, newValue)}
/>
);
})
}
</DescriptionList>
</div>
</div>
);
}
}
RetagPreviewRow.propTypes = {
id: PropTypes.number.isRequired,
path: PropTypes.string.isRequired,
changes: PropTypes.arrayOf(PropTypes.object).isRequired,
isSelected: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired
};
export default RetagPreviewRow;