[UI] Added playlist editing features

This commit is contained in:
emeric
2014-12-01 12:55:55 +01:00
parent 215945f91e
commit 9af5ee25d7
16 changed files with 491 additions and 84 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ ui = {
enable = true;
resources = {
docroot = "/usr/share/Wt/"
docroot = "/var/lms/docroot"
approot = "/var/lms/approot"
}
+38
View File
@@ -0,0 +1,38 @@
/*
* Copyright (C) 2014 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "AudioTypes.hpp"
namespace Database {
Playlist::Playlist()
: _public(false)
{
}
Playlist::Playlist(std::string name, Wt::Dbo::ptr<User> user, bool isPublic)
: _public(isPublic),
_user(user)
{
}
} // namespace Database
+39
View File
@@ -0,0 +1,39 @@
/*
* Copyright (C) 2014 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "AudioTypes.hpp"
namespace Database {
PlaylistEntry::PlaylistEntry()
: _position(0)
{
}
PlaylistEntry::PlaylistEntry(Wt::Dbo::ptr<Track> track, Wt::Dbo::ptr<Playlist> playlist)
: _position(0),
_track(track),
_playlist(playlist)
{
}
} // namespace Database
+2
View File
@@ -76,6 +76,8 @@ LmsApplication::LmsApplication(const Wt::WEnvironment& env, boost::filesystem::p
bootstrapTheme->setResponsive(true);
setTheme(bootstrapTheme);
useStyleSheet("css/lms.css");
// Add a resource bundle
messageResourceBundle().use(appRoot() + "templates");
+120 -66
View File
@@ -24,14 +24,13 @@
#include <Wt/WGridLayout>
#include <Wt/WComboBox>
#include <Wt/WPushButton>
#include <Wt/WPopupMenu>
#include "logger/Logger.hpp"
#include "TableFilter.hpp"
#include "TableFilterGenre.hpp"
#include "KeywordSearchFilter.hpp"
#include "TrackView.hpp"
#include "PlayQueue.hpp"
#include "Audio.hpp"
@@ -40,41 +39,37 @@ namespace UserInterface {
Audio::Audio(SessionData& sessionData, Wt::WContainerWidget* parent)
: Wt::WContainerWidget(parent),
_db(sessionData.getDatabaseHandler()),
_mediaPlayer(nullptr)
_mediaPlayer(nullptr),
_trackView(nullptr),
_playQueue(nullptr)
{
Wt::WGridLayout *mainLayout = new Wt::WGridLayout();
this->setLayout(mainLayout);
// Filters
Wt::WHBoxLayout *filterLayout = new Wt::WHBoxLayout();
{
TableFilterGenre *filter = new TableFilterGenre(_db);
filterLayout->addWidget(filter);
_filterChain.addFilter(filter);
}
{
TableFilter *filter = new TableFilter(_db, "track", "artist_name");
filterLayout->addWidget(filter);
_filterChain.addFilter(filter);
}
{
TableFilter *filter = new TableFilter(_db, "track", "release_name");
filterLayout->addWidget(filter);
_filterChain.addFilter(filter);
}
TableFilterGenre *filterGenre = new TableFilterGenre(_db);
filterLayout->addWidget(filterGenre);
_filterChain.addFilter(filterGenre);
TableFilter *filterArtist = new TableFilter(_db, "track", "artist_name", "Artist");
filterLayout->addWidget(filterArtist);
_filterChain.addFilter(filterArtist);
TableFilter *filterRelease = new TableFilter(_db, "track", "release_name", "Release");
filterLayout->addWidget(filterRelease);
_filterChain.addFilter(filterRelease);
mainLayout->addLayout(filterLayout, 0, 0);
// ENDOF(Filters)
// TODO ADD controls (Play, add)
// TODO ADD some stats (nb files, total duration, etc.)
Wt::WVBoxLayout* trackLayout = new Wt::WVBoxLayout();
TrackView* trackView = new TrackView(_db);
trackLayout->addWidget(trackView, 1);
_trackView = new TrackView(_db);
trackLayout->addWidget(_trackView, 1);
Wt::WHBoxLayout* trackControls = new Wt::WHBoxLayout();
@@ -90,9 +85,9 @@ _mediaPlayer(nullptr)
mainLayout->addLayout(trackLayout, 1, 0);
_filterChain.addFilter(trackView);
_filterChain.addFilter(_trackView);
PlayQueue *playQueue = new PlayQueue(_db);
_playQueue = new PlayQueue(_db);
// Playlist/PlayQueue
{
@@ -103,12 +98,49 @@ _mediaPlayer(nullptr)
Wt::WHBoxLayout* playlistControls = new Wt::WHBoxLayout();
playlistControls->addWidget(new Wt::WPushButton("Playlist"));
playlistControls->addWidget(new Wt::WText("Duration:"));
Wt::WPushButton *saveBtn = new Wt::WPushButton("Save");
playlistControls->addWidget(saveBtn);
Wt::WPushButton *loadBtn = new Wt::WPushButton("Load");
playlistControls->addWidget(loadBtn);
Wt::WPushButton *upBtn = new Wt::WPushButton("UP");
playlistControls->addWidget(upBtn);
Wt::WPushButton *downBtn = new Wt::WPushButton("DO");
playlistControls->addWidget(downBtn);
Wt::WPushButton *delBtn = new Wt::WPushButton("DEL");
playlistControls->addWidget(delBtn);
delBtn->clicked().connect(_playQueue, &PlayQueue::delSelected);
upBtn->clicked().connect(_playQueue, &PlayQueue::moveSelectedUp);
downBtn->clicked().connect(_playQueue, &PlayQueue::moveSelectedDown);
playlistControls->addWidget(new Wt::WText("Duration:"), 1);
// Load menu
{
Wt::WPopupMenu *popup = new Wt::WPopupMenu();
popup->addItem("Metal");
popup->addItem("Test");
loadBtn->setMenu(popup);
}
// Save Menu
{
Wt::WPopupMenu *popup = new Wt::WPopupMenu();
popup->addItem("New");
popup->addSeparator();
popup->addItem("Metal");
popup->addItem("Test");
saveBtn->setMenu(popup);
}
playQueueLayout->addLayout(playlistControls);
playQueueLayout->addWidget( playQueue, 1);
playQueueLayout->addWidget( _playQueue, 1);
mainLayout->addLayout(playQueueLayout, 0, 1, 2, 1);
}
@@ -122,54 +154,27 @@ _mediaPlayer(nullptr)
// Double click on track
// Set the selected tracks to the play queue
trackView->trackDoubleClicked().connect(std::bind([=] ()
{
std::vector<Database::Track::id_type> trackIds;
trackView->getSelectedTracks(trackIds);
_trackView->trackDoubleClicked().connect(boost::bind(&Audio::playSelectedTracks, this, PlayQueueAddSelectedTracks));
playQueue->clear();
playQueue->addTracks(trackIds);
playQueue->play();
}));
// Double click on artist
// Set the selected tracks to the play queue
filterArtist->sigDoubleClicked().connect(boost::bind(&Audio::playSelectedTracks, this, PlayQueueAddAllTracks));
filterRelease->sigDoubleClicked().connect(boost::bind(&Audio::playSelectedTracks, this, PlayQueueAddAllTracks));
filterGenre->sigDoubleClicked().connect(boost::bind(&Audio::playSelectedTracks, this, PlayQueueAddAllTracks));
// Play button
// Set the selected tracks to the play queue
playBtn->clicked().connect(std::bind([=] ()
{
std::vector<Database::Track::id_type> trackIds;
trackView->getSelectedTracks(trackIds);
// If nothing is selected, get the whole track list
if (trackIds.empty())
trackView->getTracks(trackIds);
playQueue->clear();
playQueue->addTracks(trackIds);
playQueue->play();
}));
playBtn->clicked().connect(boost::bind(&Audio::playSelectedTracks, this, PlayQueueAddSelectedTracks));
// Add Button
// Add the selected tracks at the end of the play queue
addBtn->clicked().connect(std::bind([=] ()
{
std::vector<Database::Track::id_type> trackIds;
trackView->getSelectedTracks(trackIds);
addBtn->clicked().connect(this, &Audio::addSelectedTracks);
// If nothing is selected, get the whole track list
if (trackIds.empty())
trackView->getTracks(trackIds);
_playQueue->playTrack().connect(this, &Audio::playTrack);
playQueue->addTracks(trackIds);
}));
playQueue->playTrack().connect(this, &Audio::playTrack);
_mediaPlayer->playbackEnded().connect(playQueue, &PlayQueue::handlePlaybackComplete);
_mediaPlayer->playNext().connect(playQueue, &PlayQueue::playNext);
_mediaPlayer->playPrevious().connect(playQueue, &PlayQueue::playPrevious);
_mediaPlayer->playbackEnded().connect(_playQueue, &PlayQueue::handlePlaybackComplete);
_mediaPlayer->playNext().connect(_playQueue, &PlayQueue::playNext);
_mediaPlayer->playPrevious().connect(_playQueue, &PlayQueue::playPrevious);
}
void
@@ -178,6 +183,55 @@ Audio::search(const std::string& searchText)
_filterChain.searchKeyword(searchText);
}
void
Audio::playSelectedTracks(PlayQueueAddType addType)
{
int firstTrackPos = 0;
std::vector<Database::Track::id_type> trackIds;
switch(addType)
{
case PlayQueueAddAllTracks:
// Play all the tracks, from the first one
firstTrackPos = 0;
_trackView->getTracks(trackIds);
break;
case PlayQueueAddSelectedTracks:
// If nothing or only ONE selected, get them all
if (_trackView->getNbSelectedTracks() <= 1)
{
// Start to play at the selected track, if any
firstTrackPos = _trackView->getFirstSelectedTrackPosition();
_trackView->getTracks(trackIds);
}
else
{
// Play all the selected tracks, frm the first one
firstTrackPos = 0;
_trackView->getSelectedTracks(trackIds);
}
break;
}
_playQueue->clear();
_playQueue->addTracks(trackIds);
_playQueue->play(firstTrackPos);
}
void
Audio::addSelectedTracks(void)
{
std::vector<Database::Track::id_type> trackIds;
_trackView->getSelectedTracks(trackIds);
// If nothing is selected, get the whole track list
if (trackIds.empty())
_trackView->getTracks(trackIds);
_playQueue->addTracks(trackIds);
}
void
Audio::playTrack(boost::filesystem::path p)
{
+13
View File
@@ -27,6 +27,8 @@
#include "common/SessionData.hpp"
#include "AudioMediaPlayer.hpp"
#include "TrackView.hpp"
#include "PlayQueue.hpp"
#include "FilterChain.hpp"
@@ -45,11 +47,22 @@ class Audio : public Wt::WContainerWidget
void playTrack(boost::filesystem::path p);
enum PlayQueueAddType
{
PlayQueueAddAllTracks,
PlayQueueAddSelectedTracks,
};
void playSelectedTracks(PlayQueueAddType addType);
void addSelectedTracks();
void handlePlaylistSelected(Wt::WString name);
Database::Handler& _db;
AudioMediaPlayer* _mediaPlayer;
TrackView* _trackView;
PlayQueue* _playQueue;
FilterChain _filterChain;
+2
View File
@@ -63,11 +63,13 @@ AudioMediaPlayer::AudioMediaPlayer( Wt::WContainerWidget *parent)
nextBtn->clicked().connect(std::bind([=] ()
{
_mediaPlayer->stop();
_playNext.emit();
}));
prevBtn->clicked().connect(std::bind([=] ()
{
_mediaPlayer->stop();
_playPrevious.emit();
}));
}
+186 -10
View File
@@ -17,15 +17,64 @@
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include <Wt/WItemDelegate>
#include "logger/Logger.hpp"
#include "PlayQueue.hpp"
namespace {
void modelForceRefreshDataRow(Wt::WStandardItemModel *model, int row)
{
for (int i = 0; i < model->columnCount(); ++i)
model->setData(row, i, model->data(row, i));
}
void swapRows(Wt::WStandardItemModel *model, int row1, int row2)
{
// Copy column by column
for (int i = 0; i < model->columnCount(); ++i)
{
boost::any tmp = model->data(row1, i); // 1 -> tmp
model->setData(row1, i, model->data(row2, i)); // 2 -> 1
model->setData(row2, i, tmp); // tmp-> 2
}
}
}
namespace UserInterface {
static const int trackIdInvalid = -1;
class PlayQueueItemDelegate : public Wt::WItemDelegate
{
public:
PlayQueueItemDelegate(Wt::WObject *parent = 0) : Wt::WItemDelegate(parent), _selectedRowId(trackIdInvalid) {}
void setSelectedRowId(int rowId) { _selectedRowId = rowId; }
Wt::WWidget* update(Wt::WWidget *widget, const Wt::WModelIndex &index, Wt::WFlags< Wt::ViewItemRenderFlag > flags)
{
Wt::WWidget* res = Wt::WItemDelegate::update(widget, index, flags);
if (res && index.isValid() && index.row() == _selectedRowId)
res->toggleStyleClass("playqueue-playing", true);
return res;
}
private:
int _selectedRowId;
};
PlayQueue::PlayQueue(Database::Handler& db, Wt::WContainerWidget* parent)
: Wt::WTableView(parent),
_db(db),
_playedId(-1)
_playedId(trackIdInvalid)
{
_model = new Wt::WStandardItemModel(0, 4, this);
@@ -45,6 +94,9 @@ _playedId(-1)
this->setColumnHidden(0, true);
_itemDelegate = new PlayQueueItemDelegate();
this->setItemDelegate(_itemDelegate);
this->doubleClicked().connect( std::bind([=] (Wt::WModelIndex idx, Wt::WMouseEvent evt)
{
if (!idx.isValid())
@@ -64,9 +116,9 @@ _playedId(-1)
}
void
PlayQueue::play(void)
PlayQueue::play(int trackId)
{
readTrack(0);
readTrack(trackId);
}
void
@@ -99,23 +151,21 @@ PlayQueue::clear(void)
_model->removeRows(0, _model->rowCount());
// Reset play id
_playedId = -1;
_playedId = trackIdInvalid;
}
void
PlayQueue::handlePlaybackComplete(void)
{
// Play the next track
if (_playedId != -1)
{
if (_playedId != trackIdInvalid)
readTrack(_playedId + 1);
}
}
void
PlayQueue::playNext(void)
{
readTrack( _playedId != -1 ? _playedId + 1 : 0);
readTrack( _playedId != trackIdInvalid ? _playedId + 1 : 0);
}
void
@@ -136,15 +186,141 @@ PlayQueue::readTrack(int rowId)
Database::Track::pointer track = Database::Track::getById(_db.getSession(), trackId);
if (track)
{
_playedId = rowId;
setPlayingTrackId(rowId);
_sigTrackPlay.emit(track->getPath());
break;
return;
}
++rowId;
}
}
void
PlayQueue::setPlayingTrackId(int newRowId)
{
int oldRowId = _playedId;
_playedId = newRowId;
_itemDelegate->setSelectedRowId(newRowId);
// Hack re-set the data in order to trigger the rerending of the widget
// calling the update method of our custom item delegate gives bad results
if (oldRowId >= 0)
modelForceRefreshDataRow(_model, oldRowId);
if (newRowId >= 0)
modelForceRefreshDataRow(_model, newRowId);
}
void
PlayQueue::delSelected(void)
{
int minId = _model->rowCount();
Wt::WModelIndexSet indexSet = this->selectedIndexes();
BOOST_REVERSE_FOREACH(Wt::WModelIndex index, indexSet)
{
_model->removeRow(index.row());
if (index.row() < minId)
minId = index.row();
}
// If the current played track is removed, make sure to unselect it
_itemDelegate->setSelectedRowId(trackIdInvalid);
renumber(minId, _model->rowCount() - 1);
}
void
PlayQueue::moveSelectedUp(void)
{
int minId = _model->rowCount();
int maxId = 0;
Wt::WModelIndexSet indexSet = this->selectedIndexes();
Wt::WModelIndexSet newIndexSet;
// ordered from up to down
BOOST_FOREACH(Wt::WModelIndex index, indexSet)
{
// Do nothing if the first selected index is on the top
if (index.row() == 0)
return;
// TODO optimize for blocks
swapRows(_model, index.row() - 1, index.row());
if (index.row() - 1 < minId)
minId = index.row() - 1;
if (index.row() > maxId)
maxId = index.row();
// Update playing status
if (_playedId == index.row())
setPlayingTrackId(_playedId - 1);
else if (_playedId == index.row() - 1)
setPlayingTrackId(_playedId + 1);
newIndexSet.insert( _model->index(index.row() - 1, 0));
}
this->setSelectedIndexes( newIndexSet );
renumber(minId, maxId);
}
void
PlayQueue::moveSelectedDown(void)
{
int minId = _model->rowCount();
int maxId = 0;
Wt::WModelIndexSet indexSet = this->selectedIndexes();
Wt::WModelIndexSet newIndexSet;
// ordered from up to down
BOOST_REVERSE_FOREACH(Wt::WModelIndex index, indexSet)
{
// Do nothing if the last selected index is the last one
if (index.row() == _model->rowCount() - 1)
return;
// TODO optimize for blocks
swapRows(_model, index.row(), index.row() + 1);
if (index.row() < minId)
minId = index.row();
if (index.row() + 1 > maxId)
maxId = index.row() + 1;
// Update playing status
if (_playedId == index.row())
setPlayingTrackId(_playedId + 1);
else if (_playedId == index.row() + 1)
setPlayingTrackId(_playedId - 1);
newIndexSet.insert( _model->index(index.row() + 1, 0));
}
this->setSelectedIndexes( newIndexSet );
renumber(minId, maxId);
}
void
PlayQueue::renumber(int firstId, int lastId)
{
for (int i = firstId; i <= lastId; ++i)
_model->setData( i, 1, i + 1);
}
} // namespace UserInterface
+13 -1
View File
@@ -28,6 +28,8 @@
namespace UserInterface {
class PlayQueueItemDelegate;
class PlayQueue : public Wt::WTableView
{
public:
@@ -37,10 +39,16 @@ class PlayQueue : public Wt::WTableView
void clear(void);
void play(void); // Play the queue from the beginning
// Play functions
void play(int trackId = 0); // Play the queue
void playNext(void); // Play the next track
void playPrevious(void); // Play the previous track
// List manipulations
void delSelected(void);
void moveSelectedUp(void);
void moveSelectedDown(void);
// Signals
Wt::Signal< boost::filesystem::path >& playTrack() { return _sigTrackPlay; }
@@ -52,6 +60,8 @@ class PlayQueue : public Wt::WTableView
private:
void readTrack(int rowId);
void setPlayingTrackId(int newRowId);
void renumber(int firstId, int lastId);
Wt::Signal< boost::filesystem::path > _sigTrackPlay;
@@ -59,6 +69,8 @@ class PlayQueue : public Wt::WTableView
Wt::WStandardItemModel* _model;
PlayQueueItemDelegate* _itemDelegate;
int _playedId;
};
+18 -3
View File
@@ -25,7 +25,7 @@
namespace UserInterface {
TableFilter::TableFilter(Database::Handler& db, std::string table, std::string field, Wt::WContainerWidget* parent)
TableFilter::TableFilter(Database::Handler& db, std::string table, std::string field, const Wt::WString& displayName, Wt::WContainerWidget* parent)
: Wt::WTableView( parent ),
Filter(),
_db(db),
@@ -33,8 +33,8 @@ _table(table),
_field(field)
{
_queryModel.setQuery( _db.getSession().query< ResultType >("select track." + _field + ", COUNT(DISTINCT track.id) from track GROUP BY track." + _field).orderBy("track." + _field));
_queryModel.addColumn( "track." + _field, field);
_queryModel.addColumn( "COUNT(DISTINCT track.id)", "tracks");
_queryModel.addColumn( "track." + _field, displayName);
_queryModel.addColumn( "COUNT(DISTINCT track.id)", "Tracks");
this->setSelectionMode(Wt::ExtendedSelection);
this->setSortingEnabled(false);
@@ -46,6 +46,21 @@ _field(field)
setLayoutSizeAware(true);
_queryModel.setBatchSize(100);
// If an item is double clicked, select and emit signal
this->doubleClicked().connect( std::bind([=] (Wt::WModelIndex idx, Wt::WMouseEvent evt)
{
if (!idx.isValid())
return;
Wt::WModelIndexSet indexSet;
indexSet.insert(idx);
this->setSelectedIndexes( indexSet );
_sigDoubleClicked.emit( );
}, std::placeholders::_1, std::placeholders::_2));
}
void
+7 -1
View File
@@ -33,7 +33,7 @@ class TableFilter : public Wt::WTableView, public Filter
{
public:
TableFilter(Database::Handler& db, std::string table, std::string field, Wt::WContainerWidget* parent = 0);
TableFilter(Database::Handler& db, std::string table, std::string field, const Wt::WString& displayName, Wt::WContainerWidget* parent = 0);
// Set constraints on this filter
void refresh(const Constraint& constraint);
@@ -43,8 +43,14 @@ class TableFilter : public Wt::WTableView, public Filter
void layoutSizeChanged (int width, int height);
typedef Wt::Signal<void> SigDoubleClicked;
SigDoubleClicked& sigDoubleClicked() { return _sigDoubleClicked; }
protected:
SigDoubleClicked _sigDoubleClicked;
Database::Handler& _db;
const std::string _table;
const std::string _field;
+14
View File
@@ -44,6 +44,20 @@ _db(db)
setLayoutSizeAware(true);
_queryModel.setBatchSize(100);
// If an item is double clicked, select and emit signal
this->doubleClicked().connect( std::bind([=] (Wt::WModelIndex idx, Wt::WMouseEvent evt)
{
if (!idx.isValid())
return;
Wt::WModelIndexSet indexSet;
indexSet.insert(idx);
this->setSelectedIndexes( indexSet );
_sigDoubleClicked.emit( );
}, std::placeholders::_1, std::placeholders::_2));
}
void
+6
View File
@@ -43,8 +43,14 @@ class TableFilterGenre : public Wt::WTableView, public Filter
void layoutSizeChanged (int width, int height);
typedef Wt::Signal<void> SigDoubleClicked;
SigDoubleClicked& sigDoubleClicked() { return _sigDoubleClicked; }
protected:
SigDoubleClicked _sigDoubleClicked;
Database::Handler& _db;
// genre_name, count
+21
View File
@@ -149,6 +149,27 @@ TrackView::getSelectedTracks(std::vector<Database::Track::id_type>& track_ids)
LMS_LOG(MOD_UI, SEV_DEBUG) << "Getting all selected tracks DONE...";
}
std::size_t
TrackView::getNbSelectedTracks(void)
{
return this->selectedIndexes().size();
}
int
TrackView::getFirstSelectedTrackPosition(void)
{
Wt::WModelIndexSet indexSet = this->selectedIndexes();
BOOST_FOREACH(Wt::WModelIndex index, indexSet)
{
if (!index.isValid())
continue;
return index.row();
}
return 0;
}
void
TrackView::getTracks(std::vector<Database::Track::id_type>& trackIds)
{
+6 -2
View File
@@ -43,9 +43,13 @@ class TrackView : public Wt::WTableView, public Filter
// Create constraints for child filters (N/A)
void getConstraint(Constraint& constraint) {}
// Get all the tracks that are currently selected
void getSelectedTracks(std::vector<Database::Track::id_type>& track_ids);
void getSelectedTracks(std::vector<Database::Track::id_type>& trackIds);
std::size_t getNbSelectedTracks(void);
// Get the first position of the selected tracks (0 if nothing selected)
int getFirstSelectedTrackPosition(void);
// Get all the tracks
void getTracks(std::vector<Database::Track::id_type>& track_ids);
+5
View File
@@ -0,0 +1,5 @@
.playqueue-playing {
font-weight: bold;
}