251 lines
7.6 KiB
JavaScript
Executable File
251 lines
7.6 KiB
JavaScript
Executable File
import React, { useState, useEffect } from 'react';
|
|
import { connect, useSelector, useDispatch } from 'react-redux';
|
|
import { bindActionCreators } from 'redux';
|
|
import { find, groupBy, map } from 'lodash';
|
|
import VolumeControl from './VolumeControl';
|
|
import MuteControl from './MuteControl';
|
|
import Icon from '../Icon';
|
|
import Thumbnail from '../Thumbnail';
|
|
import LinksSentence from '../LinksSentence';
|
|
import * as coreActions from '../../services/core/actions';
|
|
import * as mopidyActions from '../../services/mopidy/actions';
|
|
import * as pusherActions from '../../services/pusher/actions';
|
|
import * as snapcastActions from '../../services/snapcast/actions';
|
|
import { sortItems, indexToArray } from '../../util/arrays';
|
|
import { formatImages, digestMopidyImages } from '../../util/format';
|
|
import { titleCase } from '../../util/helpers';
|
|
import { I18n } from '../../locale';
|
|
|
|
const Header = ({ stream, server }) => {
|
|
const dispatch = useDispatch();
|
|
const current_server = useSelector((state) => state.mopidy.current_server);
|
|
const {
|
|
id,
|
|
meta,
|
|
status,
|
|
} = stream || {};
|
|
const canBeSelected = server?.id && server.id !== current_server;
|
|
const onClick = () => {
|
|
if (!canBeSelected) return;
|
|
dispatch(mopidyActions.setCurrentServer(server));
|
|
}
|
|
const images = meta?.images ? formatImages(digestMopidyImages(server, meta.images)) : null;
|
|
console.debug(images, meta.images)
|
|
return (
|
|
<div className="output-control__output__header">
|
|
<Thumbnail
|
|
images={images}
|
|
size="small"
|
|
className="output-control__output__header__thumbnail"
|
|
/>
|
|
<div className="output-control__output__header__content">
|
|
<h5
|
|
className="output-control__output__header__title"
|
|
onClick={onClick}
|
|
style={{ cursor: canBeSelected ? 'pointer' : 'default' }}
|
|
>
|
|
{server?.name || id}
|
|
{status === 'playing' && <Icon name="play_arrow" />}
|
|
</h5>
|
|
{meta && (
|
|
<ul className="details">
|
|
<li>{meta?.name}</li>
|
|
<li><LinksSentence items={meta?.artists} type="artist" nolinks /></li>
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const Clients = ({ clients }) => {
|
|
const dispatch = useDispatch();
|
|
|
|
return (
|
|
<div className="output-control__output__clients">
|
|
{clients.map((client) => {
|
|
const {
|
|
id,
|
|
name,
|
|
mute,
|
|
volume,
|
|
} = client;
|
|
return (
|
|
<div className="output-control__output__clients__item" key={client.id}>
|
|
<h5 className="output-control__output__clients__item__title">
|
|
{titleCase(name)}
|
|
</h5>
|
|
<div className="output-control__output__clients__item__volume">
|
|
<MuteControl
|
|
noTooltip
|
|
mute={mute}
|
|
onMuteChange={(value) => dispatch(snapcastActions.setClientMute(id, value))}
|
|
/>
|
|
<VolumeControl
|
|
volume={volume}
|
|
mute={mute}
|
|
onVolumeChange={
|
|
(value) => dispatch(snapcastActions.setClientVolume(id, value))
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const Group = ({
|
|
item: {
|
|
stream,
|
|
server,
|
|
clients,
|
|
},
|
|
}) => {
|
|
return (
|
|
<div className="output-control__output">
|
|
<Header stream={stream} server={server} />
|
|
<Clients clients={clients} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const Outputs = () => {
|
|
const groups = indexToArray(useSelector((state) => state.snapcast.groups || {}));
|
|
const clients = useSelector((state) => state.snapcast.clients || {});
|
|
const streams = useSelector((state) => state.snapcast.streams || {});
|
|
const servers = indexToArray(useSelector((state) => state.mopidy.servers || {}));
|
|
const groupsByStream = groupBy(groups, 'stream_id');
|
|
|
|
return (
|
|
<>
|
|
{map(groupsByStream, (groups, stream_id) => {
|
|
const clients_ids = groups.reduce((acc, curr) => [...acc, ...curr.clients_ids], []);
|
|
const group = {
|
|
stream: find(streams, (s) => s.id === stream_id),
|
|
server: find(servers, (s) => s.snapcast_stream === stream_id),
|
|
clients: clients_ids.map((id) => clients[id]).filter((c) => c.connected),
|
|
};
|
|
return <Group item={group} key={stream_id} />;
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const Commands = () => {
|
|
const dispatch = useDispatch();
|
|
const commandsObj = useSelector((state) => state.pusher.commands || {});
|
|
if (!commandsObj) return null;
|
|
|
|
let items = indexToArray(commandsObj);
|
|
if (items.length <= 0) return null;
|
|
|
|
items = sortItems(items, 'sort_order');
|
|
|
|
return (
|
|
<div className="output-control__commands commands">
|
|
{
|
|
items.map((command) => (
|
|
<div
|
|
key={command.id}
|
|
className="commands__item commands__item--interactive"
|
|
onClick={() => dispatch(pusherActions.runCommand(command.id))}
|
|
>
|
|
<Icon className="commands__item__icon" name={command.icon} />
|
|
<span className={`${command.colour}-background commands__item__background`} />
|
|
</div>
|
|
))
|
|
}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const OutputControl = ({
|
|
force_expanded,
|
|
pusher_commands,
|
|
snapcast_enabled,
|
|
}) => {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const handleClick = (e) => {
|
|
if (!force_expanded && $(e.target).closest('.output-control').length <= 0) {
|
|
setExpanded(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (force_expanded && !expanded) {
|
|
setExpanded(true);
|
|
}
|
|
}, [force_expanded]);
|
|
|
|
useEffect(() => {
|
|
if (expanded) {
|
|
window.addEventListener('click', handleClick, false);
|
|
} else {
|
|
window.removeEventListener('click', handleClick, false);
|
|
}
|
|
}, [expanded]);
|
|
|
|
// No customisable outputs
|
|
if (!snapcast_enabled && !pusher_commands) {
|
|
return (
|
|
<span className="output-control disabled">
|
|
<button className="control speakers">
|
|
<Icon name="speaker" />
|
|
</button>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (expanded) {
|
|
const outputs = <Outputs />;
|
|
const commands = <Commands />;
|
|
return (
|
|
<span className="output-control">
|
|
<button
|
|
className="control speakers active"
|
|
onClick={() => setExpanded(false)}
|
|
>
|
|
<Icon name="speaker" />
|
|
</button>
|
|
{!outputs && !commands ? (
|
|
<div className="output-control__inner output-control__inner--no-results">
|
|
<p className="no-results">
|
|
<I18n path="playback_controls.no_outputs" />
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="output-control__inner">
|
|
{commands}
|
|
{outputs}
|
|
</div>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span className="output-control">
|
|
<button
|
|
className="control speakers"
|
|
onClick={() => setExpanded(true)}
|
|
>
|
|
<Icon name="speaker" />
|
|
</button>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const mapStateToProps = (state) => ({
|
|
pusher_connected: state.pusher.connected,
|
|
snapcast_enabled: (state.pusher.config ? state.pusher.config.snapcast_enabled : null),
|
|
});
|
|
|
|
const mapDispatchToProps = (dispatch) => ({
|
|
coreActions: bindActionCreators(coreActions, dispatch),
|
|
snapcastActions: bindActionCreators(snapcastActions, dispatch),
|
|
pusherActions: bindActionCreators(pusherActions, dispatch),
|
|
});
|
|
|
|
export default connect(mapStateToProps, mapDispatchToProps)(OutputControl);
|