Files
Iris/src/js/components/TrackList.js
2022-09-29 15:32:11 +13:00

230 lines
6.4 KiB
JavaScript
Executable File

import React, { useState, useEffect } from 'react';
import { range, uniqBy } from 'lodash';
import Track from './Track';
import { playURIs, changeTrack } from '../services/mopidy/actions';
import { createNotification, showContextMenu } from '../services/ui/actions';
import { isTouchDevice } from '../util/helpers';
import { arrayOf } from '../util/arrays';
import { SmartList } from './SmartList';
import { useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
const TrackList = ({
context,
className = '',
show_source_icon,
tracks,
playTracks,
removeTracks,
reorderTracks,
}) => {
const dispatch = useDispatch();
const play_state = useSelector((state) => state.mopidy.play_state);
const { slim_mode } = useSelector((state) => state.ui);
const [selected, setSelected] = useState([]);
useEffect(() => {
setSelected([]);
}, [tracks.length]);
useEffect(() => {
window.addEventListener('keydown', onKeyDown, false);
return () => {
window.removeEventListener('keydown', onKeyDown, false);
};
}, [selected]);
const onKeyDown = (e) => {
const {
key,
altKey,
ctrlKey,
metaKey,
shiftKey,
} = e;
// When we're focussed on certian elements, don't fire any shortcuts
// Typically form inputs
const ignoreNodes = ['INPUT', 'TEXTAREA'];
if (ignoreNodes.indexOf(e.target.nodeName) > -1) {
return;
}
// Ignore when there are any key modifiers. This enables us to avoid interfering
// with browser- and OS-default functions.
if (altKey || ctrlKey || metaKey || shiftKey) {
return;
}
switch (key.toLowerCase()) {
case 'enter':
onPlayTracks();
e.preventDefault();
break;
case 'backspace':
case 'delete':
onRemoveTracks();
e.preventDefault();
break;
case 'a':
setSelected(tracks.map((item, index) => ({ item, index })));
e.preventDefault();
break;
default:
break;
}
}
const events = {
onDrop: (index) => {
reorderTracks(arrayOf('index', selected), index);
setSelected([]);
},
onMouseDown: (item, index, e) => {
if (selectionIndexByItemIndex(index) === -1) {
e.persist();
setSelected((prev) => nextSelected(prev, item, index, e));
}
},
onTouchStart: (item, index, e) => { // Only bound to the drag handler
e.persist();
setSelected((prev) => nextSelected(prev, item, index, e));
},
onDoubleClick: (item, index) => {
setSelected([{ item, index }]);
if (context?.type === 'queue') {
dispatch(changeTrack(item.tlid));
} else {
dispatch(playURIs({ uris: [item.uri], from: context }));
}
},
onContextMenu: (item, index, e) => {
// Do our best to stop any flow-on events
e.preventDefault();
e.stopPropagation();
e.cancelBubble = true;
const items = getOrUpdateSelected(item, index, e).map(
({ index, item }) => ({ index, ...item }),
);
dispatch(showContextMenu({
e,
context,
...(items.length === 1
? { type: 'track', item: items[0] }
: { type: 'tracks', items }
),
}));
},
};
const onPlayTracks = () => {
if (!selected || !selected.length) return;
const selectedTracks = arrayOf('item', selected);
if (playTracks) {
playTracks(selectedTracks);
} else {
dispatch(playURIs({ uris: arrayOf('uri', selectedTracks), from: context }));
}
};
const onRemoveTracks = () => {
if (!selected || !selected.length) return;
if (!removeTracks) {
dispatch(createNotification({
content: `Cannot delete ${selected.length > 1 ? 'these tracks' : 'this track'}`,
level: 'error',
}));
return;
}
removeTracks(selected.map(({ index }) => index));
};
const selectionIndexByItemIndex = (index) => (
selected && selected.length > 0
? selected.findIndex(({ index: selectedIndex }) => selectedIndex === index)
: -1
);
// Based on a single track's event, procure the next selected array and update the state
// Target not selected = select it
// Target selected = leave as-is and return current selection
const getOrUpdateSelected = (item, index, e) => {
const alreadySelected = selectionIndexByItemIndex(index);
let items = [];
if (alreadySelected > -1) {
items = selected;
} else {
items = nextSelected(selected, item, index, e);
setSelected(items);
}
return items;
}
const nextSelected = (prev, item, index, e = {}, sticky = false) => {
const alreadySelected = selectionIndexByItemIndex(index);
if (e.shiftKey) {
const { index: lastSelectedIndex } = selected[selected.length - 1] || {
index,
};
const from = lastSelectedIndex < index ? lastSelectedIndex : index;
const to = lastSelectedIndex > index ? lastSelectedIndex : index;
const newSelection = range(from, to + 1).map((index) => ({
index,
item: tracks[index],
}));
return uniqBy([...prev, ...newSelection], "index");
}
if (e.ctrlKey) {
if (alreadySelected >= 0 && !sticky) {
const next = [...prev];
next.splice(alreadySelected, 1);
return next;
}
return [...prev, { item, index }];
}
return [{ item, index }];
};
if (!tracks || Object.prototype.toString.call(tracks) !== '[object Array]') {
return null;
}
const is_selected = (index) => selected.find(({ index: i }) => index === i);
const getDragItem = (item, index) => {
let selectedForDrag = selected;
if (selectionIndexByItemIndex(index) === -1) {
selectedForDrag = nextSelected(selected, item, index);
}
return {
items: selectedForDrag,
context,
};
};
return (
<SmartList
className={`list list--tracks ${className}`}
items={tracks}
itemComponent={Track}
itemProps={{
play_state,
show_source_icon,
context,
getDragItem,
can_sort: context?.can_edit,
is_selected,
mini_zones: slim_mode || isTouchDevice(),
events,
}}
/>
);
};
export default TrackList;