Merge branch 'develop'

This commit is contained in:
James Barnsley
2023-05-20 15:09:37 +12:00
28 changed files with 352 additions and 461 deletions

View File

@ -53,7 +53,7 @@ jobs:
name: ${{ matrix.name }}
runs-on: ubuntu-20.04
container: ghcr.io/mopidy/ci:latest
container: ghcr.io/mopidy/ci:7
steps:
- name: Checkout code
uses: actions/checkout@v2

View File

@ -32,13 +32,11 @@ RUN apt update \
WORKDIR /usr/src/gst-plugins-rs
# Clone source of gst-plugins-rs to workdir
ARG GST_PLUGINS_RS_TAG=main
ARG GST_PLUGINS_RS_TAG=0.10.5
RUN git clone -c advice.detachedHead=false \
--single-branch --depth 1 \
--branch ${GST_PLUGINS_RS_TAG} \
https://gitlab.freedesktop.org/gstreamer/gst-plugins-rs.git ./
# EXPERIMENTAL: For gstreamer-spotify set upgraded version number of dependency librespot to 0.4.2
RUN sed -i 's/librespot = { version = "0.4", default-features = false }/librespot = { version = "0.4.2", default-features = false }/g' audio/spotify/Cargo.toml
# Build GStreamer plugins written in Rust (optional with --no-default-features)
ENV DEST_DIR /target/gst-plugins-rs
@ -99,9 +97,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_14.x | bash - && \
# Install mopidy and (optional) DLNA-server dleyna from apt.mopidy.com
# see https://docs.mopidy.com/en/latest/installation/debian/
RUN mkdir -p /usr/local/share/keyrings \
&& wget -q -O /usr/local/share/keyrings/mopidy-archive-keyring.gpg https://apt.mopidy.com/mopidy.gpg \
&& wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/buster.list \
RUN mkdir -p /etc/apt/keyrings \
&& wget -q -O /etc/apt/keyrings/mopidy-archive-keyring.gpg https://apt.mopidy.com/mopidy.gpg \
&& wget -q -O /etc/apt/sources.list.d/mopidy.list https://apt.mopidy.com/bullseye.list \
&& apt-get update \
&& apt-get install -y \
mopidy \
@ -133,7 +131,7 @@ RUN git clone --depth 1 --single-branch -b ${IRIS_VERSION} https://github.com/ja
# Install mopidy-spotify-gstspotify (Hack, not released yet!)
# (https://github.com/kingosticks/mopidy-spotify/tree/gstspotifysrc-hack)
RUN git clone --depth 1 -b gstspotifysrc-hack https://github.com/kingosticks/mopidy-spotify.git mopidy-spotify \
RUN git clone --depth 1 https://github.com/mopidy/mopidy-spotify.git mopidy-spotify \
&& cd mopidy-spotify \
&& python3 setup.py install \
&& cd .. \

81
Dockerfile.alpine Normal file
View File

@ -0,0 +1,81 @@
# Use Alpine edge for now as some required dependencies are only in the testing repository
FROM alpine:edge
# Switch to the root user while we do our changes
USER root
WORKDIR /
# Install GStreamer and other required Debian packages
RUN echo "http://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories \
&& apk update \
&& apk add \
dumb-init \
shadow \
sudo \
git \
py3-pip \
mopidy \
py3-mopidy-spotify
# Install Node, to build Iris JS application
RUN apk add nodejs npm
# Upgrade Python package manager pip
# https://pypi.org/project/pip/
RUN python3 -m pip install --upgrade pip
# Clone Iris from the repository and install in development mode.
# This allows a binding at "/iris" to map to your local folder for development, rather than
# installing using pip.
# Note: ADD helps prevent RUN caching issues. When HEAD changes in repo, our cache will be invalidated!
ADD https://api.github.com/repos/jaedb/Iris/git/refs/heads/master version.json
ENV IRIS_VERSION=develop
RUN git clone --depth 1 --single-branch -b ${IRIS_VERSION} https://github.com/jaedb/Iris.git /iris \
&& cd /iris \
&& npm install \
&& npm run prod \
&& python3 setup.py develop \
&& mkdir -p /var/lib/mopidy/.config \
&& ln -s /config /var/lib/mopidy/.config/mopidy \
# Allow mopidy user to run system commands (restart, local scan, etc)
&& echo "mopidy ALL=NOPASSWD: /iris/mopidy_iris/system.sh" >> /etc/sudoers \
# Enable container mode (disable restart option, etc.)
&& echo "1" >> /IS_CONTAINER \
# Copy Version file
&& cp /iris/VERSION /
# Install additional mopidy extensions and Python dependencies via pip
COPY docker/requirements.txt .
RUN python3 -m pip install -r requirements.txt
# Cleanup
RUN rm -rf /root/.cache \
&& rm -rf /iris/node_modules
# Start helper script.
COPY docker/entrypoint.sh /entrypoint.sh
# Copy Default configuration for mopidy
COPY docker/mopidy/mopidy.example.conf /config/mopidy.conf
# Copy the pulse-client configuratrion
COPY docker/mopidy/pulse-client.conf /etc/pulse/client.conf
# Allows any user to run mopidy, but runs by default as a randomly generated UID/GID.
# RUN useradd -ms /bin/bash mopidy
ENV HOME=/var/lib/mopidy
RUN set -ex \
&& usermod -G audio,wheel mopidy \
&& mkdir /var/lib/mopidy/local \
&& chown mopidy:audio -R $HOME /entrypoint.sh /iris \
&& chmod go+rwx -R $HOME /entrypoint.sh /iris
# Runs as mopidy user by default.
USER mopidy:audio
VOLUME ["/var/lib/mopidy/local"]
EXPOSE 6600 6680 1704 1705 5555/udp
ENTRYPOINT ["/usr/bin/dumb-init", "/entrypoint.sh"]
CMD ["mopidy"]

View File

@ -1 +1 @@
3.66.1
3.67.0

View File

@ -7,6 +7,7 @@ include mopidy_iris/ext.conf
include mopidy_iris/system.sh
include IRIS_VERSION
include Dockerfile
include Dockerfile.alpine
include pyproject.toml
include screenshot.jpg
include tox.ini

View File

@ -1,14 +1,14 @@
#!/bin/bash
#!/bin/sh
if [ -z "$PULSE_COOKIE_DATA" ]
then
echo -ne $(echo $PULSE_COOKIE_DATA | sed -e 's/../\\x&/g') >$HOME/pulse.cookie
export PULSE_COOKIE=$HOME/pulse.cookie
printf '%s' "$(echo "$PULSE_COOKIE_DATA" | sed -e 's/../\\x&/g')" >"$HOME"/pulse.cookie
export PULSE_COOKIE="$HOME"/pulse.cookie
fi
if [ ${PIP_PACKAGES:+x} ]; then
echo "-- INSTALLING PIP PACKAGES $PIP_PACKAGES --"
python3 -m pip install --no-cache $PIP_PACKAGES
python3 -m pip install --no-cache --upgrade "$PIP_PACKAGES"
fi
exec "$@"

View File

@ -94,7 +94,7 @@
},
"scripts": {
"test": "jest",
"tox": "docker exec -it -u root iris_mopidy_1 bash -c \"cd /iris && tox\"",
"tox": "docker exec -it -u root mopidy bash -c \"cd /iris && tox\"",
"start": "NODE_ENV=development WEBPACK_DEV_SERVER=1 webpack-dev-server",
"lint": "eslint src",
"lint:fix": "eslint src --fix",

View File

@ -116,7 +116,7 @@
</body>
<script type="text/javascript">
if ('serviceWorker' in navigator){
navigator.serviceWorker.register('service-worker.js')
navigator.serviceWorker.register('<%= baseHref %>service-worker.js')
.then(function(registration){
console.log('Service worker registered');
}).catch(function(error){

View File

@ -53,7 +53,7 @@ const Content = () => (
<Route path="queue/history" element={<QueueHistory />}/>
<Route path="settings/debug" element={<Debug />} />
<Route path="settings/*" element={<Settings />} />
<Route path="search/" element={<Search />} />
<Route path="search" element={<Search />} />
<Route path="search/:type/:term" element={<Search />} />
<Route path="artist/:uri/*" element={<Artist />} />
<Route path="album/:uri/" element={<Album />} />

View File

@ -3,6 +3,7 @@ import { useDispatch } from 'react-redux';
import { compact } from 'lodash';
import { I18n } from '../../locale';
import Link from '../Link';
import Loader from '../Loader';
import Icon from '../Icon';
import { encodeUri } from '../../util/format';
import {

View File

@ -1,15 +1,12 @@
import React, { useState, useEffect } from 'react';
import { connect, useSelector, useDispatch } from 'react-redux';
import { bindActionCreators } from 'redux';
import { find, groupBy, map } from 'lodash';
import { useSelector, useDispatch } from 'react-redux';
import { find, groupBy, map, isEmpty } from 'lodash';
import VolumeControl from './VolumeControl';
import MuteControl from './MuteControl';
import Icon from '../Icon';
import Thumbnail from '../Thumbnail';
import LinksSentence from '../LinksSentence';
import DropdownField from './DropdownField';
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';
@ -166,18 +163,10 @@ const Group = ({
};
const Outputs = () => {
const snapcastEnabled = useSelector((state) => state.snapcast.enabled);
const allGroups = indexToArray(useSelector((state) => state.snapcast.groups || {}));
const allStreams = useSelector((state) => state.snapcast.streams || {});
const allServers = indexToArray(useSelector((state) => state.mopidy.servers || {}));
const groupsByStream = groupBy(allGroups, 'stream_id');
if (!snapcastEnabled) {
return (
<p className="no-results">
<I18n path="playback_controls.snapcast_not_enabled" />
</p>
);
}
return (
<ErrorBoundary>
@ -199,12 +188,10 @@ const Outputs = () => {
);
}
const Commands = () => {
const Commands = ({ commands }) => {
const dispatch = useDispatch();
const commandsObj = useSelector((state) => state.pusher.commands || {});
if (!commandsObj) return null;
let items = indexToArray(commandsObj);
let items = indexToArray(commands);
if (items.length <= 0) return null;
items = sortItems(items, 'sort_order');
@ -230,6 +217,8 @@ const Commands = () => {
};
const OutputControl = ({ force_expanded }) => {
const snapcastEnabled = useSelector((state) => state.snapcast.enabled);
const commands = useSelector((state) => state.pusher.commands);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
@ -238,9 +227,9 @@ const OutputControl = ({ force_expanded }) => {
}
}, [force_expanded]);
if (!snapcastEnabled && isEmpty(commands)) return null;
if (expanded) {
const outputs = <Outputs />;
const commands = <Commands />;
return (
<span className="output-control">
{!force_expanded && <div className="click-outside" onClick={() => setExpanded(false)} />}
@ -251,8 +240,8 @@ const OutputControl = ({ force_expanded }) => {
<Icon name="speaker" />
</button>
<div className="output-control__inner">
{commands}
{outputs}
{!isEmpty(commands) && <Commands commands={commands} />}
{snapcastEnabled && <Outputs />}
</div>
</span>
);

View File

@ -1,5 +1,6 @@
import React from 'react';
import { connect } from 'react-redux';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { sortItems } from '../util/arrays';
import URILink from './URILink';
import Icon from './Icon';
@ -9,40 +10,25 @@ import { I18n } from '../locale';
import Button from './Button';
import { makeSearchResultsSelector, getSortSelector } from '../util/selectors';
const SORT_KEY = 'search_results';
const SearchResults = ({
type,
query,
sortField,
sortReverse: sortReverseProp,
uri_schemes_priority,
all,
results: rawResults,
}) => {
const encodedTerm = encodeURIComponent(query.term);
let results = rawResults;
let sortReverse = sortReverseProp;
if (!results) return null;
let sort_map = null;
switch (sortField) {
case 'uri':
sort_map = uri_schemes_priority;
break;
case 'followers':
// Followers (aka popularlity works in reverse-numerical order)
// Ie "more popular" is a bigger number
sortReverse = !sortReverse;
break;
default:
break;
}
const { term } = useParams();
const [sortField, sortReverse] = useSelector(
(state) => getSortSelector(state, SORT_KEY, 'name'),
);
const searchResultsSelector = makeSearchResultsSelector(term, type);
const rawResults = useSelector(searchResultsSelector);
const encodedTerm = encodeURIComponent(term);
let results = [...rawResults];
results = sortItems(
results,
(type === 'tracks' && sortField === 'followers' ? 'popularity' : sortField),
sortReverse,
sort_map,
);
const resultsCount = results.length;
@ -50,7 +36,7 @@ const SearchResults = ({
results = results.slice(0, 6);
}
if (results.length <= 0) return null;
if (all && !results.length) return null;
return (
<div>
@ -72,56 +58,59 @@ const SearchResults = ({
</URILink>
)}
</h4>
<section className="grid-wrapper">
{type === 'artists' && <Grid items={results} show_source_icon mini={all} />}
{type === 'albums' && <Grid items={results} show_source_icon mini={all} />}
{type === 'playlists' && <Grid items={results} show_source_icon mini={all} />}
{type === 'tracks' && (
<TrackList
source={{
uri: `iris:search:${query.type}:${encodedTerm}`,
name: 'Search results',
type: 'search',
}}
tracks={results}
show_source_icon
/>
)}
{/* <LazyLoadListener enabled={this.props.artists_more && spotify_search_enabled} loadMore={loadMore} /> */}
{results.length > 0 && (
<section className="grid-wrapper">
{type === 'artists' && <Grid items={results} show_source_icon mini={all} />}
{type === 'albums' && <Grid items={results} show_source_icon mini={all} />}
{type === 'playlists' && <Grid items={results} show_source_icon mini={all} />}
{type === 'tracks' && (
<TrackList
source={{
uri: `iris:search:${type}:${encodedTerm}`,
name: 'Search results',
type: 'search',
}}
tracks={results}
show_source_icon
/>
)}
{/* <LazyLoadListener enabled={this.props.artists_more && spotify_search_enabled} loadMore={loadMore} /> */}
{resultsCount > results.length && (
<Button uri={`iris:search:${type}:${encodedTerm}`} uriType="search" unencoded>
<I18n path={`search.${type}.more`} count={resultsCount} />
</Button>
)}
</section>
{resultsCount > results.length && (
<Button uri={`iris:search:${type}:${encodedTerm}`} uriType="search" unencoded>
<I18n path={`search.${type}.more`} count={resultsCount} />
</Button>
)}
</section>
)}
</div>
);
};
const mapStateToProps = (state, ownProps) => {
const {
query: {
term,
},
type,
} = ownProps;
const {
ui: {
uri_schemes_priority = [],
},
} = state;
const searchResultsSelector = makeSearchResultsSelector(term, type);
const { sortField, sortReverse } = getSortSelector(state, 'search_results');
const AllSearchResults = () => (
<>
<div className="search-result-sections cf">
<section className="search-result-sections__item">
<div className="inner">
<SearchResults type="artists" all />
</div>
</section>
<section className="search-result-sections__item">
<div className="inner">
<SearchResults type="albums" all />
</div>
</section>
<section className="search-result-sections__item">
<div className="inner">
<SearchResults type="playlists" all />
</div>
</section>
</div>
<SearchResults type="tracks" all />
</>
);
return {
results: searchResultsSelector(state),
uri_schemes_priority,
sortField,
sortReverse,
};
};
const mapDispatchToProps = () => ({});
export default connect(mapStateToProps, mapDispatchToProps)(SearchResults);
export {
SearchResults,
AllSearchResults,
}

View File

@ -58,6 +58,11 @@ common:
filter: Filtrer
by: ' par '
loading_library: 'Chargement de %{provider} %{type}'
play_state:
playing: Lecture en cours
paused: En pause
stopped: Arrêté
idle: Inactif
fields:
start_typing: 'Commencez à taper...'
search: 'Rechercher...'
@ -117,7 +122,8 @@ specs:
playlists: '%{count} listes de lecture'
plays: '%{count} lectures'
popularity: '%{percent}% popularité'
added: 'Ajouté '
added_from: 'Ajouté à partir de '
added_by: 'Ajouté par %{by}'
added_ago: 'Ajouté il y a %{time}'
edited: 'Edited '
edited_ago: 'Édité il y a %{time}'
@ -132,6 +138,7 @@ services:
searching: 'Recherche dans %{provider} %{type}'
searching_providers: 'Recherche parmi %{count} Mopidy services'
loading_albums: 'Chargement de %{count} albums locaux'
loading_artwork: 'Chargement des illustrations'
spotify:
title: Spotify
pusher:
@ -148,6 +155,12 @@ services:
title: LastFM
love: Aimer
unlove: Ne plus aimer
youtube:
title: YouTube
tidal:
title: Tidal
jellyfin:
title: Jellyfin
##
# Components
@ -184,6 +197,7 @@ context_menu:
unpin: Ne plus épingler
go_to_artist: Vers l'artiste
go_to_user: Vers l'utilisateur
go_to_track: Vers le morceau
track_info: Info sur le morceau
discover_similar: Découvrir des morceaux similaires
start_radio: Démarrer la radio
@ -210,6 +224,7 @@ snapcast:
latency: Latence
stream: Flux
volume: Volume
not_controllable: Non contrôlable
encryption:
label: Chiffrement
sublabel: Utiliser SSL
@ -406,7 +421,10 @@ settings:
upgrade_available: 'Mise-à-jour disponible (%{version})'
upgrade: 'Mettre à jour vers %{version}'
up_to_date: À jour
share_configuration: Partager la configuration
shared_configuration:
label: Partager la configuration
share: Exporter/partager
import: Importer depuis un serveur
restart: Redémarrer le serveur
reset: Réinitialiser tous les paramètres
about:
@ -420,6 +438,7 @@ settings:
no_connections: Pas de connexion
you: ' (vous)'
servers:
current_track: Morceau actuel
inactive: Inactif
connecting: Connexion en cours
disconnected: Déconnecté
@ -463,12 +482,9 @@ user:
you: Vous
discover:
title: Découvrir
categories:
moods:
title: 'Genre / Ambiance'
category:
title: 'Genre / Ambiance'
category: Categorie
featured:
featured_playlists:
title: Listes de lecture en vedette
new_releases:
title: Nouveautés
@ -553,23 +569,46 @@ modal:
title: Lecture en cours
title_window: '%{name} par %{artist} (lecture en cours)'
lyrics: Paroles
share_configuration:
title: Partager la configuration
subtitle: Partager vos autorisations et vos paramètres d'interface avec un autre utilisateur connecté
import_configuration:
title: Importer la configuration
configurations: Configurations à importer
import_now: Importer maintenant
shared_config:
no_peers: Aucune autre connexion
recipients: Destinataire
configurations: Configurations
authorization: '%{service} autorisation'
logged_in_as: 'Connecté comme %{name}'
snapcast_description: Détail de la connexion
interface: Paramètres d'interface
interface_description: Thème, tri, filtres, etc.
import:
title: Configuration partagée
subtitle: 'Un autre utilisateur a partagé sa configuration avec vous. Elle contient :'
do_you_want_to_import: Voulez-vous importer cette configuration ?
import_now: Importer
successful: Import réussi
import_now: Importer maintenant
imported: Import réussi
share: Partager maintenant
shared: Partage réussi
push:
title: Partage de configuration
subtitle: "Partager les autorisations et les paramètres de l'interface avec un autre utilisateur connecté"
pushed:
title: Configuration reçue
subtitle: Un autre utilisateur a partagé sa configuration avec vous
server:
title: Import de configuration
subtitle: Importer des autorisations et autres paramètres
label: Serveur
description: "Sauvegarder sur le serveur pour permettre l'import par d'autres utilisateurs"
config:
label: Configurations
ui:
label: Interface utilisateur
description: Thème, tri, filtres, etc
spotify:
label: Autorisation Spotify
description: 'Connecté en tant que %{name}'
lastfm:
label: Autorisation LastFM
description: 'Connecté en tant que %{name}'
genius:
label: Authorisation Genius
description: 'Connecté en tant que %{name}'
snapcast:
label: Snapcast
description: Détails de la connexion
reset:
title: Réinitialiser Iris
subtitle: Nettoyer la configuration, le stockage ou le cache
@ -593,6 +632,9 @@ modal:
title: Touches multimédia
keys:
info: Multimédia info
now_playing: Lecture en cours
search: Recherche
kiosk_mode: Mode kiosque
exit: Annuler l'opération en cours
play_pause: Lire/pause
stop: Stop

View File

@ -180,9 +180,10 @@ const CoreMiddleware = (function () {
const {
ui: {
allow_reporting,
uri_schemes_search_enabled = [],
search_settings,
},
mopidy: {
uri_schemes = [],
} = {},
} = store.getState();
if (allow_reporting) {
@ -198,15 +199,13 @@ const CoreMiddleware = (function () {
// Trigger reducer immediately; this will hose out any previous results
next(action);
if (uri_schemes_search_enabled.includes('spotify:')) {
if (!search_settings || search_settings.spotify) {
store.dispatch(spotifyActions.getSearchResults(query));
}
if (uri_schemes.includes('spotify:')) {
store.dispatch(spotifyActions.getSearchResults(query));
}
store.dispatch(mopidyActions.getSearchResults(
query,
100,
uri_schemes_search_enabled.filter((i) => i !== 'spotify:'), // Omit Spotify; handled above
uri_schemes.filter((i) => i !== 'spotify:'), // Omit Spotify; handled above
));
break;
}

View File

@ -848,12 +848,6 @@ const MopidyMiddleware = (function () {
// Enable Iris providers when the backend is available
store.dispatch(spotifyActions.set({ enabled: uri_schemes.includes('spotify:') }));
store.dispatch(googleActions.set({ enabled: uri_schemes.includes('gmusic:') }));
// If we haven't customised our search schemes, add all to search
if (store.getState().ui.uri_schemes_search_enabled === undefined) {
store.dispatch(uiActions.set({ uri_schemes_search_enabled: uri_schemes }));
}
store.dispatch({ type: 'MOPIDY_URI_SCHEMES', uri_schemes });
},
);

View File

@ -20,7 +20,6 @@ import {
makeProvidersSelector,
getSortSelector,
} from '../../util/selectors';
import { formatSimpleObject } from '../../util/format';
const SORT_KEY = 'library_artists';
const processKeys = [

View File

@ -124,7 +124,6 @@ const ShareConfig = () => {
'grid_glow_enabled',
'sort',
'initial_setup_complete',
'uri_schemes_search_enabled',
],
),
snapcast: pick(snapcast, ['enabled', 'host', 'port', 'ssl']),

View File

@ -1,294 +1,101 @@
import React from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import React, { useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import Header from '../components/Header';
import Icon from '../components/Icon';
import DropdownField from '../components/Fields/DropdownField';
import SearchForm from '../components/Fields/SearchForm';
import SearchResults from '../components/SearchResults';
import * as coreActions from '../services/core/actions';
import * as uiActions from '../services/ui/actions';
import * as mopidyActions from '../services/mopidy/actions';
import * as spotifyActions from '../services/spotify/actions';
import { titleCase } from '../util/helpers';
import { withRouter } from '../util';
import { AllSearchResults, SearchResults } from '../components/SearchResults';
import { startSearch } from '../services/core/actions';
import {
setSort,
hideContextMenu,
setWindowTitle,
} from '../services/ui/actions';
import { i18n } from '../locale';
import { getSortSelector } from '../util/selectors';
class Search extends React.Component {
constructor(props) {
super(props);
this.state = { term: props.term || '' };
}
const SORT_KEY = 'search_results';
componentDidMount = () => {
const {
uiActions: {
setWindowTitle,
},
} = this.props;
const Search = () => {
const { term, type = 'all' } = useParams();
const dispatch = useDispatch();
const navigate = useNavigate();
const lastQuery = useSelector((state) => state.core?.search_results?.query);
const [sortField, sortReverse] = useSelector(
(state) => getSortSelector(state, SORT_KEY, 'name'),
);
setWindowTitle('Search');
// Auto-focus on the input field
useEffect(() => {
dispatch(setWindowTitle('Search'));
$(document).find('.search-form input').focus();
this.digestUri();
}
}, []);
componentDidUpdate = ({ term: prevTerm }) => {
const { term: termProp } = this.props;
if (prevTerm !== termProp) {
this.search();
useEffect(() => {
if (term && type && term !== lastQuery?.term) {
dispatch(setWindowTitle(i18n('search.title_window', { term: decodeURIComponent(term) })));
dispatch(startSearch({ term, type }));
}
}, [term, type])
const onSubmit = (nextTerm) => {
const encodedTerm = encodeURIComponent(nextTerm);
navigate(`/search/${type}/${encodedTerm}`);
}
onSubmit = (term) => {
const { navigate, type } = this.props;
const encodedTerm = encodeURIComponent(term);
this.setState(
{ term },
() => {
navigate(`/search/${type}/${encodedTerm}`);
},
);
}
onReset = () => {
const { navigate } = this.props;
navigate('/search');
}
onSortChange = (value) => {
const { uiActions: { hideContextMenu } } = this.props;
this.setSort(value);
hideContextMenu();
}
onSourceChange = (value) => {
const {
uiActions: {
set,
hideContextMenu,
},
} = this.props;
set({ uri_schemes_search_enabled: value });
hideContextMenu();
}
onSourceClose = () => {
this.search(true);
};
digestUri = () => {
const { term } = this.props;
if (term) {
this.setState({ term }, this.search);
} else {
this.clearSearch();
}
}
clearSearch = () => {
const {
uiActions: {
setWindowTitle,
},
} = this.props;
setWindowTitle(i18n('search.title'));
this.setState({ term: '' });
}
search = (force = false) => {
const {
coreActions: {
startSearch,
},
uiActions: {
setWindowTitle,
},
search_results_query: {
type: existingType,
term: existingTerm,
},
type,
} = this.props;
const { term } = this.state;
setWindowTitle(i18n('search.title_window', { term: decodeURIComponent(term) }));
if ((type && term && (force || existingType !== type || existingTerm !== term))) {
startSearch({ type, term });
}
}
setSort = (value) => {
const {
sort,
sort_reverse,
uiActions: {
set,
},
} = this.props;
const onReset = () => navigate('/search');
const onSortChange = (field) => {
let reverse = false;
if (sort === value) reverse = !sort_reverse;
const data = {
search_results_sort_reverse: reverse,
search_results_sort: value,
};
set(data);
}
render = () => {
const { term } = this.state;
const {
uri_schemes,
sort,
sort_reverse,
uri_schemes_search_enabled,
uiActions,
type,
} = this.props;
const sort_options = [
{ value: 'followers', label: i18n('common.popularity') },
{ value: 'name', label: i18n('common.name') },
{ value: 'artist', label: i18n('common.artist') },
{ value: 'duration', label: i18n('common.duration') },
];
const provider_options = uri_schemes.map((item) => ({
value: item,
label: titleCase(item.replace(':', '').replace('+', ' ')),
}));
const options = (
<>
<DropdownField
icon="swap_vert"
name={i18n('common.sort')}
value={sort}
valueAsLabel
options={sort_options}
selected_icon={sort_reverse ? 'keyboard_arrow_up' : 'keyboard_arrow_down'}
handleChange={this.onSortChange}
/>
<DropdownField
icon="cloud"
name={i18n('common.sources')}
value={uri_schemes_search_enabled}
options={provider_options}
handleChange={this.onSourceChange}
onClose={this.onSourceClose}
/>
</>
);
let searchResults;
switch (type) {
case 'artists':
searchResults = <SearchResults type="artists" query={{ term, type: 'artists' }} />;
break;
case 'albums':
searchResults = <SearchResults type="albums" query={{ term, type: 'albums' }} />;
break;
case 'playlists':
searchResults = <SearchResults type="playlists" query={{ term, type: 'playlists' }} />;
break;
case 'tracks':
searchResults = <SearchResults type="tracks" query={{ term, type: 'tracks' }} />
break;
default:
searchResults = (
<>
<div className="search-result-sections cf">
<section className="search-result-sections__item">
<div className="inner">
<SearchResults type="artists" query={{ term, type: 'artists' }} all />
</div>
</section>
<section className="search-result-sections__item">
<div className="inner">
<SearchResults type="albums" query={{ term, type: 'albums' }} all />
</div>
</section>
<section className="search-result-sections__item">
<div className="inner">
<SearchResults type="playlists" query={{ term, type: 'playlists' }} all />
</div>
</section>
</div>
<SearchResults type="tracks" query={{ term, type: 'tracks' }} all />
</>
);
if (field !== null && sortField === field) {
reverse = !sortReverse;
}
return (
<div className="view search-view">
<Header options={options} uiActions={uiActions}>
<Icon name="search" type="material" />
</Header>
<SearchForm
key={`search_form_${type}_${term}`}
term={term}
onSubmit={this.onSubmit}
onReset={this.onReset}
/>
<div className="content-wrapper">
{searchResults}
</div>
</div>
);
dispatch(setSort(SORT_KEY, field, reverse));
dispatch(hideContextMenu());
}
const sortOptions = [
{ value: 'name', label: i18n('common.name') },
{ value: 'uri', label: i18n('fields.filters.source') },
{ value: 'followers', label: i18n('common.popularity') },
{ value: 'artist', label: i18n('common.artist') },
{ value: 'duration', label: i18n('common.duration') },
];
const options = (
<DropdownField
icon="swap_vert"
name={i18n('common.sort')}
value={sortField}
options={sortOptions}
selected_icon={sortField ? (sortReverse ? 'keyboard_arrow_up' : 'keyboard_arrow_down') : null}
handleChange={onSortChange}
valueAsLabel
/>
);
return (
<div className="view search-view">
<Header options={options}>
<Icon name="search" type="material" />
</Header>
<SearchForm
key={`search_form_${type}_${term}`}
term={term}
onSubmit={onSubmit}
onReset={onReset}
/>
<div className="content-wrapper">
{type != 'all' ? (
<SearchResults type={type} />
) : (
<AllSearchResults />
)}
</div>
</div>
);
}
const mapStateToProps = (state, ownProps) => {
const {
params: {
type,
term,
},
navigation,
} = ownProps;
const {
mopidy: {
uri_schemes = [],
},
ui: {
uri_schemes_search_enabled = [],
search_results_sort: sort = 'followers.total',
search_results_sort_reverse,
},
core: {
search_results: {
query: search_results_query = {},
} = {},
},
} = state;
return {
type: type || 'all',
term,
navigation,
uri_schemes,
uri_schemes_search_enabled,
sort,
sort_reverse: !!search_results_sort_reverse,
search_results_query,
};
};
const mapDispatchToProps = (dispatch) => ({
coreActions: bindActionCreators(coreActions, dispatch),
uiActions: bindActionCreators(uiActions, dispatch),
mopidyActions: bindActionCreators(mopidyActions, dispatch),
spotifyActions: bindActionCreators(spotifyActions, dispatch),
});
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Search));
export default Search;

View File

@ -1,4 +1,6 @@
{
"start_url": ".",
"scope": ".",
"manifest_version": "3.55",
"short_name": "Iris",
"name": "Iris",
@ -24,7 +26,6 @@
"sizes": "512x512"
}
],
"start_url": "/",
"background_color": "#08d58f",
"theme_color": "#181818",
"display": "standalone",

View File

@ -106,7 +106,8 @@
}
&--loader {
padding-top: 75%;
text-align: center;
margin-top: 50%;
}
}

View File

@ -11,6 +11,10 @@
border-bottom: 0 !important;
cursor: pointer;
a {
text-decoration: none !important;
}
&__wrapper {
display: inline-block;
}

View File

@ -111,6 +111,7 @@
cursor: pointer;
color: colour('white');
border: 0 !important;
text-decoration: none !important;
margin: 0 5px;
&:hover {

View File

@ -17,7 +17,6 @@
padding: 0 5px;
display: block;
box-sizing: border-box;
border: none !important;
cursor: pointer;
&__inner {
@ -60,6 +59,8 @@
}
&:hover {
text-decoration: none;
.menu-item__inner {
background: lighten(colour('dark_grey'), 4%);

View File

@ -7,8 +7,6 @@
margin-right: 25px;
font-size: 15px;
font-weight: 500;
border-bottom: 0;
padding-bottom: 3px;
cursor: pointer;
@include theme('light') {
@ -21,25 +19,7 @@
&--active,
&:hover {
border-bottom-width: 3px !important;
border-bottom-style: solid;
padding-bottom: 0px;
}
&--active {
border-color: colour('white') !important;
@include theme('light') {
border-color: colour('darkest_grey') !important;
}
}
&:not(.sub-views__option--active):hover {
border-color: colour('soft_grey') !important;
@include theme('light') {
border-color: colour('light_grey') !important;
}
text-decoration: underline 0.15em;
}
}

View File

@ -194,12 +194,8 @@ main {
text-decoration: none;
cursor: pointer;
&:not(.control):not(.action):not(.button) {
border-bottom: 1px solid transparent;
&:hover {
border-color: colour('mid_grey');
}
&:hover {
text-decoration: underline 0.15em;
}
}
@ -262,17 +258,16 @@ h2 {
a {
color: inherit;
text-decoration: none;
text-decoration: none 2px;
&:hover {
border-bottom: 2px solid colour('white');
text-decoration: underline 2px;
}
}
&.grey-text {
a:hover {
color: colour('mid_grey') !important;
border-bottom: 2px solid colour('mid_grey');
}
}
}
@ -342,7 +337,7 @@ h5 {
text-decoration: none;
&:hover {
text-decoration: underline;
text-decoration: underline 0.15em;
}
}
}

View File

@ -214,6 +214,10 @@ select {
transform: translate(1px, 1px);
}
&:hover {
text-decoration: none;
}
@include theme('light') {
&--default {
border-color: colour('darkest_grey');

View File

@ -66,7 +66,7 @@
.biography-text {
overflow-wrap: break-word;
white-space: pre-wrap;
white-space: pre-wrap;
}
}
}

View File

@ -5,18 +5,18 @@
position: absolute;
top: 30px;
left: 90px;
right: 270px;
right: 150px;
input {
@include feature_font();
padding: 6px 0 4px 0;
padding: 6px 0 0 0;
width: 100%;
font-size: 3rem;
font-weight: 700;
height: 4rem;
height: 1em;
background: transparent;
border: 0;
border-bottom: 2px solid colour('grey');
border-bottom: 2px solid rgba(128,128,128,0.3);
border-radius: 0;
letter-spacing: -0.15rem;
@ -24,6 +24,11 @@
&:not(:root:root) {
height: 4.6rem;
margin-top: -0.5rem;
@include responsive($bp_medium) {
height: 2.6rem;
margin-top: 0;
}
}
&:focus,