Play/pause snapcast streams using meta_mpd.py plugin

This commit is contained in:
James Barnsley
2022-02-19 23:14:39 +13:00
parent 79e7fc88c7
commit c53e4fe525
6 changed files with 175 additions and 81 deletions

View File

@ -13,70 +13,78 @@ 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';
import { I18n, i18n } from '../../locale';
import Link from '../Link';
import ErrorBoundary from '../ErrorBoundary';
const Header = ({ stream, server }) => {
const Header = ({
stream,
server,
}) => {
const dispatch = useDispatch();
const {
id,
meta: {
name,
artists,
images: rawImages,
} = {},
status,
uri: {
scheme,
query: {
control_url,
properties: {
playbackStatus,
canPlay,
canPause,
canControl,
metadata: {
title,
artist: artists = [],
artUrl,
} = {},
} = {},
} = stream || {};
const controlURL = control_url ? new URL(control_url) : null;
const controlServer = controlURL ? {
url: control_url,
ssl: controlURL.protocol === 'https:',
host: controlURL.hostname,
port: controlURL.port || (controlURL.protocol === 'https:' ? '443' : '80'),
} : null;
const images = rawImages ? formatImages(digestMopidyImages(controlServer, rawImages)) : null;
const current_server_id = useSelector((state) => state.mopidy.current_server);
const current_server = useSelector((state) => state.mopidy.servers[current_server_id]);
const isCurrentServer = control_url === current_server.url;
const isControlSwitchable = controlServer && !isCurrentServer;
const onClick = () => {
if (isControlSwitchable) {
dispatch(mopidyActions.setCurrentServer(controlServer));
}
};
let onClick = null;
switch (playbackStatus) {
case 'playing':
if (canPause) onClick = () => dispatch(snapcastActions.controlStream(id, 'pause'));
break;
default:
if (canPlay) onClick = () => dispatch(snapcastActions.controlStream(id, 'play'));
break;
}
return (
<div className="output-control__stream__header">
<Thumbnail
images={images}
size="small"
className="output-control__stream__header__thumbnail"
/>
<div
className={[
'output-control__stream__header__art',
`output-control__stream__header__art--${onClick !== null ? playbackStatus : 'disabled'}`
].join(' ')}
onClick={onClick}
>
<Thumbnail
image={artUrl}
size="small"
/>
</div>
<div className="output-control__stream__header__content">
<h5 className="output-control__stream__header__title tooltip">
{isControlSwitchable ? (
<a onClick={onClick} style={{ cursor: 'pointer' }}>
{server?.name || id}
</a>
) : (server?.name || id)}
{isCurrentServer && <Icon name="check" />}
{status === 'playing' && <Icon name="play_arrow" />}
{control_url && <div className="tooltip__content">{control_url}</div>}
{server?.name || id}
{!canControl && (
<span className="flag">
{i18n('snapcast.not_controllable').toUpperCase()}
</span>
)}
</h5>
<ul className="details">
<li>{name || scheme || 'Unknown'}</li>
{artists && <li><LinksSentence items={artists} type="artist" nolinks /></li>}
</ul>
{!title && !artists?.length ? (
<div className="details">
<I18n path={`common.play_state.${playbackStatus || status}`} />
</div>
) : (
<ul className="details">
<li>{title}</li>
{artists && (
<li>
<LinksSentence items={artists.map((name) => ({ name }))} type="artist" nolinks />
</li>
)}
</ul>
)}
</div>
</div>
);

View File

@ -334,6 +334,35 @@ class Services extends React.Component {
return (
<div className="sub-tabs__menu menu" id="services-menu">
<div className="menu__inner">
<Link
history={history}
className="menu-item menu-item--snapcast"
activeClassName="menu-item--active"
to="/settings/services/snapcast"
scrollTo="#services-menu"
>
<div className="menu-item__inner">
<Icon className="menu-item__icon" name="devices" />
<div className="menu-item__title">
<I18n path="services.snapcast.title" />
</div>
{!snapcastEnabled && (
<span className="status mid_grey-text">
<I18n path="settings.services.disabled" />
</span>
)}
{snapcastEnabled && !snapcastConnected && (
<span className="status red-text">
<I18n path="settings.services.disconnected" />
</span>
)}
{snapcastEnabled && snapcastConnected && (
<span className="status green-text">
<I18n path="settings.services.connected" />
</span>
)}
</div>
</Link>
<Link
history={history}
className="menu-item menu-item--spotify"
@ -403,35 +432,6 @@ class Services extends React.Component {
)}
</div>
</Link>
<Link
history={history}
className="menu-item menu-item--snapcast"
activeClassName="menu-item--active"
to="/settings/services/snapcast"
scrollTo="#services-menu"
>
<div className="menu-item__inner">
<Icon className="menu-item__icon" name="devices" />
<div className="menu-item__title">
<I18n path="services.snapcast.title" />
</div>
{!snapcastEnabled && (
<span className="status mid_grey-text">
<I18n path="settings.services.disabled" />
</span>
)}
{snapcastEnabled && !snapcastConnected && (
<span className="status red-text">
<I18n path="settings.services.disconnected" />
</span>
)}
{snapcastEnabled && snapcastConnected && (
<span className="status green-text">
<I18n path="settings.services.connected" />
</span>
)}
</div>
</Link>
</div>
</div>
);

View File

@ -58,6 +58,11 @@ common:
filter: Filter
by: ' by '
loading_library: 'Loading %{provider} %{type}'
play_state:
playing: Playing
paused: Paused
stopped: Stopped
idle: Idle
fields:
start_typing: 'Start typing...'
search: 'Search...'
@ -216,6 +221,7 @@ snapcast:
latency: Latency
stream: Stream
volume: Volume
not_controllable: Not controllable
encryption:
label: Encryption
sublabel: Use SSL

View File

@ -150,6 +150,14 @@ export function calculateGroupVolume(id, clients) {
};
}
export function controlStream(id, command) {
return {
type: 'SNAPCAST_CONTROL_STREAM',
id,
command,
};
}
export function serverLoaded(server) {
return {
type: 'SNAPCAST_SERVER_LOADED',

View File

@ -100,10 +100,7 @@ const SnapcastMiddleware = (function () {
break;
case 'Stream.OnUpdate':
store.dispatch(snapcastActions.streamLoaded(message.params));
break;
case 'Stream.OnMetadata':
case 'Stream.OnProperties':
store.dispatch(snapcastActions.streamLoaded(message.params));
break;
@ -651,6 +648,24 @@ const SnapcastMiddleware = (function () {
}));
break;
case 'SNAPCAST_CONTROL_STREAM': {
const { id, command } = action;
request(store, 'Stream.Control', { id, command })
.then(
() => {
// No response body
},
(error) => {
store.dispatch(coreActions.handleException(
'Could not control stream',
error,
error.message,
));
},
);
break;
}
default:
return next(action);
}

View File

@ -69,8 +69,65 @@
margin-right: 0;
}
&__thumbnail {
&__art {
flex-shrink: 0;
width: 50px;
position: relative;
cursor: pointer;
&:before {
@include animate();
content: "\f04b";
display: block;
font-family: "FontAwesome";
position: absolute;
top: 50%;
left: 0;
bottom: 0;
right: 0;
margin-top: -9px;
text-align: center;
font-size: 18px;
color: colour('white');
z-index: 3;
opacity: 0;
}
&:after {
@include animate();
content: "";
display: block;
background: rgba(0, 0, 0, 0.75);
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
border-radius: 3px;
opacity: 0;
z-index: 2;
}
&:hover {
&:before,
&:after {
opacity: 1;
}
}
&--disabled {
cursor: default;
&:before,
&:after {
display: none;
}
}
&--playing {
&:before {
content: "\f04c";
}
}
}
&__title {