Compare commits
12 Commits
e453f8acf3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
183a53786f | ||
|
|
9f178a1cc3 | ||
|
|
d1a2bed593 | ||
|
|
92d48e459e | ||
|
|
2139bbb726 | ||
|
|
c2e0ff41ac | ||
|
|
28771e12d5 | ||
|
|
d1b9cb1210 | ||
|
|
5f79170f48 | ||
|
|
dea16676ce | ||
|
|
e9a9077ece | ||
|
|
86b5673e8a |
@@ -6,8 +6,9 @@ target_sources(qobuz-qt PRIVATE
|
||||
mainwindow.hpp
|
||||
mainwindow.cpp
|
||||
|
||||
# Queue (header-only)
|
||||
# Queue
|
||||
playqueue.hpp
|
||||
playqueue.cpp
|
||||
|
||||
# Backend (Qt wrapper around Rust FFI)
|
||||
backend/qobuzbackend.hpp
|
||||
|
||||
@@ -347,7 +347,7 @@ void Tracks::onContextMenu(const QPoint &pos)
|
||||
m_model->data(index, TrackListModel::PlaylistTrackIdRole).toLongLong();
|
||||
if (playlistTrackId > 0) {
|
||||
if (m_userPlaylists.isEmpty()) menu.addSeparator();
|
||||
auto *remFromPl = menu.addAction(tr("Remove from this playlist"));
|
||||
auto *remFromPl = menu.addAction(QIcon(":/res/icons/list-remove.svg"), tr("Remove from this playlist"));
|
||||
const qint64 curPlaylistId = m_playlistId;
|
||||
const int curRow = index.row();
|
||||
connect(remFromPl, &QAction::triggered, this, [this, curPlaylistId, playlistTrackId, curRow] {
|
||||
|
||||
37
src/main.cpp
37
src/main.cpp
@@ -1,5 +1,6 @@
|
||||
#include "mainwindow.hpp"
|
||||
#include "backend/qobuzbackend.hpp"
|
||||
#include "util/colors.hpp"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QStyleFactory>
|
||||
@@ -15,24 +16,24 @@ int main(int argc, char *argv[])
|
||||
// Accent: #FFB232 (yellow-orange), Blue: #46B3EE, Backgrounds: #191919 / #141414
|
||||
app.setStyle(QStyleFactory::create(QStringLiteral("Fusion")));
|
||||
QPalette darkPalette;
|
||||
darkPalette.setColor(QPalette::Window, QColor(0x19, 0x19, 0x19));
|
||||
darkPalette.setColor(QPalette::WindowText, QColor(0xe8, 0xe8, 0xe8));
|
||||
darkPalette.setColor(QPalette::Base, QColor(0x14, 0x14, 0x14));
|
||||
darkPalette.setColor(QPalette::AlternateBase, QColor(0x1e, 0x1e, 0x1e));
|
||||
darkPalette.setColor(QPalette::ToolTipBase, QColor(0x19, 0x19, 0x19));
|
||||
darkPalette.setColor(QPalette::ToolTipText, QColor(0xe8, 0xe8, 0xe8));
|
||||
darkPalette.setColor(QPalette::Text, QColor(0xe8, 0xe8, 0xe8));
|
||||
darkPalette.setColor(QPalette::Button, QColor(0x2a, 0x2a, 0x2a));
|
||||
darkPalette.setColor(QPalette::ButtonText, QColor(0xe8, 0xe8, 0xe8));
|
||||
darkPalette.setColor(QPalette::BrightText, QColor(0xFF, 0xB2, 0x32));
|
||||
darkPalette.setColor(QPalette::Link, QColor(0x46, 0xB3, 0xEE)); // Qobuz blue
|
||||
darkPalette.setColor(QPalette::Highlight, QColor(0xFF, 0xB2, 0x32)); // Qobuz orange
|
||||
darkPalette.setColor(QPalette::HighlightedText, QColor(0x10, 0x10, 0x10)); // dark on orange
|
||||
darkPalette.setColor(QPalette::PlaceholderText, QColor(0x66, 0x66, 0x66));
|
||||
darkPalette.setColor(QPalette::Disabled, QPalette::Text, QColor(0x55, 0x55, 0x55));
|
||||
darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor(0x55, 0x55, 0x55));
|
||||
darkPalette.setColor(QPalette::Mid, QColor(0x2f, 0x2f, 0x2f));
|
||||
darkPalette.setColor(QPalette::Dark, QColor(0x0e, 0x0e, 0x0e));
|
||||
darkPalette.setColor(QPalette::Window, Colors::WindowBg);
|
||||
darkPalette.setColor(QPalette::WindowText, Colors::LightText);
|
||||
darkPalette.setColor(QPalette::Base, Colors::BaseBg);
|
||||
darkPalette.setColor(QPalette::AlternateBase, Colors::AlternateBaseBg);
|
||||
darkPalette.setColor(QPalette::ToolTipBase, Colors::WindowBg);
|
||||
darkPalette.setColor(QPalette::ToolTipText, Colors::LightText);
|
||||
darkPalette.setColor(QPalette::Text, Colors::LightText);
|
||||
darkPalette.setColor(QPalette::Button, Colors::ButtonSurface);
|
||||
darkPalette.setColor(QPalette::ButtonText, Colors::LightText);
|
||||
darkPalette.setColor(QPalette::BrightText, Colors::QobuzOrange);
|
||||
darkPalette.setColor(QPalette::Link, Colors::QobuzBlue);
|
||||
darkPalette.setColor(QPalette::Highlight, Colors::QobuzOrange);
|
||||
darkPalette.setColor(QPalette::HighlightedText, Colors::HighlightedFg);
|
||||
darkPalette.setColor(QPalette::PlaceholderText, Colors::PlaceholderText);
|
||||
darkPalette.setColor(QPalette::Disabled, QPalette::Text, Colors::DisabledText);
|
||||
darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, Colors::DisabledText);
|
||||
darkPalette.setColor(QPalette::Mid, Colors::MidSurface);
|
||||
darkPalette.setColor(QPalette::Dark, Colors::DarkSurface);
|
||||
app.setPalette(darkPalette);
|
||||
|
||||
// Stylesheet tweaks: orange accent on scrollbars, focus rings, etc.
|
||||
|
||||
@@ -38,6 +38,27 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
|
||||
m_content = new MainContent(m_backend, m_queue, this);
|
||||
setCentralWidget(m_content);
|
||||
|
||||
setupDocks();
|
||||
setupMenuBar();
|
||||
statusBar()->showMessage(tr("Ready"));
|
||||
|
||||
setupScrobbler();
|
||||
setupGapless();
|
||||
setupMpris();
|
||||
connectBackendSignals();
|
||||
connectLibrarySignals();
|
||||
connectContentSignals();
|
||||
connectToolbarSignals();
|
||||
|
||||
// Apply playback options from saved settings
|
||||
m_backend->setReplayGain(AppSettings::instance().replayGainEnabled());
|
||||
m_backend->setGapless(AppSettings::instance().gaplessEnabled());
|
||||
|
||||
tryRestoreSession();
|
||||
}
|
||||
|
||||
void MainWindow::setupDocks()
|
||||
{
|
||||
// ---- Library dock (left) ----
|
||||
m_library = new List::Library(m_backend, this);
|
||||
m_libraryDock = new QDockWidget(tr("Library"), this);
|
||||
@@ -60,44 +81,95 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
|
||||
m_sidePanel = new SidePanel::View(m_backend, m_queue, this);
|
||||
m_sidePanel->hide();
|
||||
addDockWidget(Qt::RightDockWidgetArea, m_sidePanel);
|
||||
}
|
||||
|
||||
setupMenuBar();
|
||||
statusBar()->showMessage(tr("Ready"));
|
||||
void MainWindow::setupScrobbler()
|
||||
{
|
||||
m_scrobbler = new LastFmScrobbler(this);
|
||||
connect(m_backend, &QobuzBackend::trackChanged,
|
||||
m_scrobbler, &LastFmScrobbler::onTrackStarted);
|
||||
connect(m_backend, &QobuzBackend::positionChanged,
|
||||
m_scrobbler, &LastFmScrobbler::onPositionChanged);
|
||||
connect(m_backend, &QobuzBackend::trackFinished,
|
||||
m_scrobbler, &LastFmScrobbler::onTrackFinished);
|
||||
|
||||
// ---- Scrobbler ----
|
||||
m_scrobbler = new LastFmScrobbler(this);
|
||||
connect(m_backend, &QobuzBackend::trackChanged,
|
||||
m_scrobbler, &LastFmScrobbler::onTrackStarted);
|
||||
connect(m_backend, &QobuzBackend::positionChanged,
|
||||
m_scrobbler, &LastFmScrobbler::onPositionChanged);
|
||||
connect(m_backend, &QobuzBackend::trackFinished,
|
||||
m_scrobbler, &LastFmScrobbler::onTrackFinished);
|
||||
// Scrobble the finished track during a gapless transition
|
||||
connect(m_backend, &QobuzBackend::trackTransitioned,
|
||||
m_scrobbler, &LastFmScrobbler::onTrackFinished);
|
||||
}
|
||||
|
||||
// 1. Scrobble the finished track during a gapless transition
|
||||
connect(m_backend, &QobuzBackend::trackTransitioned,
|
||||
m_scrobbler, &LastFmScrobbler::onTrackFinished);
|
||||
void MainWindow::setupGapless()
|
||||
{
|
||||
connect(m_backend, &QobuzBackend::positionChanged, this, [this](quint64 pos, quint64 dur) {
|
||||
if (!AppSettings::instance().gaplessEnabled() || dur == 0) return;
|
||||
|
||||
// ---- Gapless Signal ----
|
||||
connect(m_backend, &QobuzBackend::positionChanged, this, [this](quint64 pos, quint64 dur) {
|
||||
if (!AppSettings::instance().gaplessEnabled() || dur == 0) return;
|
||||
// Trigger prefetch if we pass the 50% mark OR are within 60 seconds of the end
|
||||
if ((pos > dur / 2) || (dur > 60 && (dur - pos) <= 60)) {
|
||||
if (!m_nextTrackPrefetched && m_queue->canGoNext()) {
|
||||
m_nextTrackPrefetched = true; // Lock it so it only fires once
|
||||
|
||||
// Trigger prefetch if we pass the 50% mark OR are within 60 seconds of the end
|
||||
if ((pos > dur / 2) || (dur > 60 && (dur - pos) <= 60)) {
|
||||
if (!m_nextTrackPrefetched && m_queue->canGoNext()) {
|
||||
m_nextTrackPrefetched = true; // Lock it so it only fires once
|
||||
|
||||
const auto upcoming = m_queue->upcomingTracks(1);
|
||||
if (!upcoming.isEmpty()) {
|
||||
const qint64 nextId = static_cast<qint64>(upcoming.first()["id"].toDouble());
|
||||
if (nextId > 0) {
|
||||
m_backend->prefetchTrack(nextId, AppSettings::instance().preferredFormat());
|
||||
}
|
||||
const auto upcoming = m_queue->upcomingTracks(1);
|
||||
if (!upcoming.isEmpty()) {
|
||||
const qint64 nextId = static_cast<qint64>(upcoming.first()["id"].toDouble());
|
||||
if (nextId > 0) {
|
||||
m_backend->prefetchTrack(nextId, AppSettings::instance().preferredFormat());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Backend signals ----
|
||||
void MainWindow::setupMpris()
|
||||
{
|
||||
#ifdef USE_DBUS
|
||||
m_mpris = new Mpris(this);
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::playRequested, m_backend, [this] {
|
||||
if (m_backend->state() == 2) m_backend->resume();
|
||||
});
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::pauseRequested, m_backend, &QobuzBackend::pause);
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::playPauseRequested, m_backend, [this] {
|
||||
if (m_backend->state() == 1)
|
||||
m_backend->pause();
|
||||
else
|
||||
m_backend->resume();
|
||||
});
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::stopRequested, m_backend, &QobuzBackend::stop);
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::nextRequested, this, [this] {
|
||||
if (!m_queue->canGoNext()) return;
|
||||
const qint64 id = static_cast<qint64>(m_queue->advance()["id"].toDouble());
|
||||
if (id > 0) m_backend->playTrack(id);
|
||||
});
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::previousRequested, this, [this] {
|
||||
if (!m_queue->canGoPrev()) return;
|
||||
const qint64 id = static_cast<qint64>(m_queue->stepBack()["id"].toDouble());
|
||||
if (id > 0) m_backend->playTrack(id);
|
||||
});
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::seekRequested, m_backend, [this](qlonglong offsetMicroseconds) {
|
||||
qint64 newPos = m_backend->position() + (offsetMicroseconds / 1000000LL);
|
||||
if (newPos < 0) newPos = 0;
|
||||
m_backend->seek(newPos);
|
||||
});
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::seekToRequested, m_backend, [this](qlonglong positionMicroseconds) {
|
||||
m_backend->seek(positionMicroseconds / 1000000LL);
|
||||
});
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::volumeChangeRequested, m_backend, [this](double vol) {
|
||||
m_backend->setVolume(vol * 100);
|
||||
});
|
||||
|
||||
connect(m_backend, &QobuzBackend::stateChanged, this, [this](const QString &state) {
|
||||
if (state == "playing") m_mpris->player()->setPlaybackStatus("Playing");
|
||||
else if (state == "paused") m_mpris->player()->setPlaybackStatus("Paused");
|
||||
else m_mpris->player()->setPlaybackStatus("Stopped");
|
||||
});
|
||||
connect(m_backend, &QobuzBackend::positionChanged, this, [this](quint64 pos) {
|
||||
m_mpris->player()->updatePosition(pos);
|
||||
});
|
||||
#endif
|
||||
}
|
||||
|
||||
void MainWindow::connectBackendSignals()
|
||||
{
|
||||
connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess);
|
||||
connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError);
|
||||
connect(m_backend, &QobuzBackend::userLoaded, this, [this](const QJsonObject &user) {
|
||||
@@ -145,8 +217,10 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
|
||||
connect(m_backend, &QobuzBackend::error, this, [this](const QString &msg) {
|
||||
statusBar()->showMessage(tr("Error: %1").arg(msg), 6000);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Library signals ----
|
||||
void MainWindow::connectLibrarySignals()
|
||||
{
|
||||
connect(m_library, &List::Library::userPlaylistIdsChanged,
|
||||
this, [this](const QSet<qint64> &playlistIds) {
|
||||
m_userPlaylistIds = playlistIds;
|
||||
@@ -167,52 +241,6 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
|
||||
m_backend->getFavTracks();
|
||||
statusBar()->showMessage(tr("Loading favorite tracks…"));
|
||||
});
|
||||
|
||||
#ifdef USE_DBUS
|
||||
m_mpris = new Mpris(this);
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::playRequested, m_backend, [this] {
|
||||
if (m_backend->state() == 2) m_backend->resume();
|
||||
});
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::pauseRequested, m_backend, &QobuzBackend::pause);
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::playPauseRequested, m_backend, [this] {
|
||||
if (m_backend->state() == 1)
|
||||
m_backend->pause();
|
||||
else
|
||||
m_backend->resume();
|
||||
});
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::stopRequested, m_backend, &QobuzBackend::stop);
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::nextRequested, this, [this] {
|
||||
if (!m_queue->canGoNext()) return;
|
||||
const qint64 id = static_cast<qint64>(m_queue->advance()["id"].toDouble());
|
||||
if (id > 0) m_backend->playTrack(id);
|
||||
});
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::previousRequested, this, [this] {
|
||||
if (!m_queue->canGoPrev()) return;
|
||||
const qint64 id = static_cast<qint64>(m_queue->stepBack()["id"].toDouble());
|
||||
if (id > 0) m_backend->playTrack(id);
|
||||
});
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::seekRequested, m_backend, [this](qlonglong offsetMicroseconds) {
|
||||
qint64 newPos = m_backend->position() + (offsetMicroseconds / 1000000LL);
|
||||
if (newPos < 0) newPos = 0;
|
||||
m_backend->seek(newPos);
|
||||
});
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::seekToRequested, m_backend, [this](qlonglong positionMicroseconds) {
|
||||
m_backend->seek(positionMicroseconds / 1000000LL);
|
||||
});
|
||||
connect(m_mpris->player(), &MprisPlayerAdaptor::volumeChangeRequested, m_backend, [this](double vol) {
|
||||
m_backend->setVolume(vol * 100);
|
||||
});
|
||||
|
||||
connect(m_backend, &QobuzBackend::stateChanged, this, [this](const QString &state) {
|
||||
if (state == "playing") m_mpris->player()->setPlaybackStatus("Playing");
|
||||
else if (state == "paused") m_mpris->player()->setPlaybackStatus("Paused");
|
||||
else m_mpris->player()->setPlaybackStatus("Stopped");
|
||||
});
|
||||
connect(m_backend, &QobuzBackend::positionChanged, this, [this](quint64 pos) {
|
||||
m_mpris->player()->updatePosition(pos);
|
||||
});
|
||||
#endif
|
||||
|
||||
connect(m_library, &List::Library::favAlbumsRequested, this, [this] {
|
||||
m_showFavAlbumsOnLoad = true;
|
||||
m_backend->getFavAlbums();
|
||||
@@ -236,7 +264,10 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
|
||||
m_content->showPlaylistBrowser();
|
||||
statusBar()->showMessage(tr("Browse Playlists"));
|
||||
});
|
||||
}
|
||||
|
||||
void MainWindow::connectContentSignals()
|
||||
{
|
||||
// ---- Track list → playback / playlist management ----
|
||||
connect(m_content->tracksList(), &List::Tracks::playTrackRequested,
|
||||
this, &MainWindow::onPlayTrackRequested);
|
||||
@@ -300,8 +331,10 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
|
||||
// ---- Queue panel ----
|
||||
connect(m_queuePanel, &QueuePanel::skipToTrackRequested,
|
||||
this, &MainWindow::onPlayTrackRequested);
|
||||
}
|
||||
|
||||
// ---- Toolbar toggles ----
|
||||
void MainWindow::connectToolbarSignals()
|
||||
{
|
||||
connect(m_toolBar, &MainToolBar::searchToggled, this, &MainWindow::onSearchToggled);
|
||||
connect(m_toolBar, &MainToolBar::queueToggled,
|
||||
this, [this](bool v) { m_queuePanel->setVisible(v); });
|
||||
@@ -314,12 +347,15 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
|
||||
|
||||
connect(m_toolBar, &MainToolBar::albumRequested, this, &MainWindow::onSearchAlbumSelected);
|
||||
connect(m_toolBar, &MainToolBar::artistRequested, this, &MainWindow::onSearchArtistSelected);
|
||||
|
||||
// Apply playback options from saved settings
|
||||
m_backend->setReplayGain(AppSettings::instance().replayGainEnabled());
|
||||
m_backend->setGapless(AppSettings::instance().gaplessEnabled());
|
||||
|
||||
tryRestoreSession();
|
||||
connect(m_toolBar, &MainToolBar::addToPlaylistRequested,
|
||||
this, [this](qint64 trackId, qint64 playlistId) {
|
||||
m_backend->addTrackToPlaylist(playlistId, trackId);
|
||||
statusBar()->showMessage(tr("Adding track to playlist…"), 3000);
|
||||
});
|
||||
connect(m_toolBar, &MainToolBar::favTrackRequested,
|
||||
this, [this](qint64 trackId) {
|
||||
m_backend->addFavTrack(trackId);
|
||||
});
|
||||
}
|
||||
|
||||
void MainWindow::setupMenuBar()
|
||||
@@ -602,4 +638,5 @@ void MainWindow::onUserPlaylistsChanged(const QVector<QPair<qint64, QString>> &p
|
||||
m_userPlaylists = playlists;
|
||||
m_content->tracksList()->setUserPlaylists(playlists);
|
||||
m_sidePanel->searchTab()->setUserPlaylists(playlists);
|
||||
m_toolBar->setUserPlaylists(playlists);
|
||||
}
|
||||
|
||||
@@ -72,5 +72,13 @@ private:
|
||||
bool m_nextTrackPrefetched = false;
|
||||
|
||||
void setupMenuBar();
|
||||
void setupDocks();
|
||||
void setupScrobbler();
|
||||
void setupGapless();
|
||||
void setupMpris();
|
||||
void connectBackendSignals();
|
||||
void connectLibrarySignals();
|
||||
void connectContentSignals();
|
||||
void connectToolbarSignals();
|
||||
void tryRestoreSession();
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "tracklistmodel.hpp"
|
||||
#include "../util/colors.hpp"
|
||||
|
||||
#include <QJsonValue>
|
||||
#include <QColor>
|
||||
@@ -9,6 +10,56 @@ TrackListModel::TrackListModel(QObject *parent)
|
||||
: QAbstractTableModel(parent)
|
||||
{}
|
||||
|
||||
TrackItem TrackListModel::parseTrackItem(const QJsonObject &t,
|
||||
bool usePosition,
|
||||
bool useSequential,
|
||||
int &seq)
|
||||
{
|
||||
TrackItem item;
|
||||
item.id = static_cast<qint64>(t["id"].toDouble());
|
||||
item.playlistTrackId = static_cast<qint64>(t["playlist_track_id"].toDouble());
|
||||
item.discNumber = t["media_number"].toInt(1);
|
||||
item.duration = static_cast<qint64>(t["duration"].toDouble());
|
||||
item.streamable = t["streamable"].toBool(true);
|
||||
item.hiRes = t["hires_streamable"].toBool();
|
||||
item.raw = t;
|
||||
|
||||
// Combine title + version ("Melody" + "Vocal Remix" → "Melody (Vocal Remix)")
|
||||
const QString base = t["title"].toString();
|
||||
const QString version = t["version"].toString().trimmed();
|
||||
item.title = version.isEmpty() ? base
|
||||
: base + QStringLiteral(" (") + version + QLatin1Char(')');
|
||||
|
||||
if (useSequential) {
|
||||
item.number = seq++;
|
||||
} else if (usePosition) {
|
||||
const int pos = t["position"].toInt();
|
||||
item.number = pos > 0 ? pos : seq;
|
||||
++seq;
|
||||
} else {
|
||||
item.number = t["track_number"].toInt();
|
||||
}
|
||||
|
||||
const QJsonObject performer = t["performer"].toObject();
|
||||
item.artist = performer["name"].toString();
|
||||
if (item.artist.isEmpty()) {
|
||||
// album.artist.name may be a plain string or {display:"..."} object
|
||||
const QJsonValue n = t["album"].toObject()["artist"].toObject()["name"];
|
||||
item.artist = n.isObject() ? n.toObject()["display"].toString() : n.toString();
|
||||
}
|
||||
if (item.artist.isEmpty()) {
|
||||
// top_tracks format: artist.name.display
|
||||
const QJsonValue n = t["artist"].toObject()["name"];
|
||||
item.artist = n.isObject() ? n.toObject()["display"].toString() : n.toString();
|
||||
}
|
||||
|
||||
const QJsonObject album = t["album"].toObject();
|
||||
item.album = album["title"].toString();
|
||||
item.albumId = album["id"].toString();
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
void TrackListModel::setTracks(const QJsonArray &tracks,
|
||||
bool usePosition,
|
||||
bool useSequential)
|
||||
@@ -22,52 +73,8 @@ void TrackListModel::setTracks(const QJsonArray &tracks,
|
||||
parsed.reserve(tracks.size());
|
||||
|
||||
int seq = 1;
|
||||
for (const QJsonValue &v : tracks) {
|
||||
const QJsonObject t = v.toObject();
|
||||
TrackItem item;
|
||||
item.id = static_cast<qint64>(t["id"].toDouble());
|
||||
item.playlistTrackId = static_cast<qint64>(t["playlist_track_id"].toDouble());
|
||||
item.discNumber = t["media_number"].toInt(1);
|
||||
item.duration = static_cast<qint64>(t["duration"].toDouble());
|
||||
item.streamable = t["streamable"].toBool(true);
|
||||
item.hiRes = t["hires_streamable"].toBool();
|
||||
item.raw = t;
|
||||
|
||||
// Combine title + version ("Melody" + "Vocal Remix" → "Melody (Vocal Remix)")
|
||||
const QString base = t["title"].toString();
|
||||
const QString version = t["version"].toString().trimmed();
|
||||
item.title = version.isEmpty() ? base
|
||||
: base + QStringLiteral(" (") + version + QLatin1Char(')');
|
||||
|
||||
if (useSequential) {
|
||||
item.number = seq++;
|
||||
} else if (usePosition) {
|
||||
const int pos = t["position"].toInt();
|
||||
item.number = pos > 0 ? pos : seq;
|
||||
++seq;
|
||||
} else {
|
||||
item.number = t["track_number"].toInt();
|
||||
}
|
||||
|
||||
const QJsonObject performer = t["performer"].toObject();
|
||||
item.artist = performer["name"].toString();
|
||||
if (item.artist.isEmpty()) {
|
||||
// album.artist.name may be a plain string or {display:"..."} object
|
||||
const QJsonValue n = t["album"].toObject()["artist"].toObject()["name"];
|
||||
item.artist = n.isObject() ? n.toObject()["display"].toString() : n.toString();
|
||||
}
|
||||
if (item.artist.isEmpty()) {
|
||||
// top_tracks format: artist.name.display
|
||||
const QJsonValue n = t["artist"].toObject()["name"];
|
||||
item.artist = n.isObject() ? n.toObject()["display"].toString() : n.toString();
|
||||
}
|
||||
|
||||
const QJsonObject album = t["album"].toObject();
|
||||
item.album = album["title"].toString();
|
||||
item.albumId = album["id"].toString();
|
||||
|
||||
parsed.append(item);
|
||||
}
|
||||
for (const QJsonValue &v : tracks)
|
||||
parsed.append(parseTrackItem(v.toObject(), usePosition, useSequential, seq));
|
||||
|
||||
// Multi-disc only makes sense for album context (not playlists / fav / search)
|
||||
int maxDisc = 1;
|
||||
@@ -134,49 +141,8 @@ void TrackListModel::appendTracks(const QJsonArray &tracks,
|
||||
|
||||
QVector<TrackItem> parsed;
|
||||
parsed.reserve(tracks.size());
|
||||
for (const QJsonValue &v : tracks) {
|
||||
const QJsonObject t = v.toObject();
|
||||
TrackItem item;
|
||||
item.id = static_cast<qint64>(t["id"].toDouble());
|
||||
item.playlistTrackId = static_cast<qint64>(t["playlist_track_id"].toDouble());
|
||||
item.discNumber = t["media_number"].toInt(1);
|
||||
item.duration = static_cast<qint64>(t["duration"].toDouble());
|
||||
item.streamable = t["streamable"].toBool(true);
|
||||
item.hiRes = t["hires_streamable"].toBool();
|
||||
item.raw = t;
|
||||
|
||||
const QString base = t["title"].toString();
|
||||
const QString version = t["version"].toString().trimmed();
|
||||
item.title = version.isEmpty() ? base
|
||||
: base + QStringLiteral(" (") + version + QLatin1Char(')');
|
||||
|
||||
if (useSequential) {
|
||||
item.number = seq++;
|
||||
} else if (usePosition) {
|
||||
const int pos = t["position"].toInt();
|
||||
item.number = pos > 0 ? pos : seq;
|
||||
++seq;
|
||||
} else {
|
||||
item.number = t["track_number"].toInt();
|
||||
}
|
||||
|
||||
const QJsonObject performer = t["performer"].toObject();
|
||||
item.artist = performer["name"].toString();
|
||||
if (item.artist.isEmpty()) {
|
||||
const QJsonValue n = t["album"].toObject()["artist"].toObject()["name"];
|
||||
item.artist = n.isObject() ? n.toObject()["display"].toString() : n.toString();
|
||||
}
|
||||
if (item.artist.isEmpty()) {
|
||||
const QJsonValue n = t["artist"].toObject()["name"];
|
||||
item.artist = n.isObject() ? n.toObject()["display"].toString() : n.toString();
|
||||
}
|
||||
|
||||
const QJsonObject album = t["album"].toObject();
|
||||
item.album = album["title"].toString();
|
||||
item.albumId = album["id"].toString();
|
||||
|
||||
parsed.append(item);
|
||||
}
|
||||
for (const QJsonValue &v : tracks)
|
||||
parsed.append(parseTrackItem(v.toObject(), usePosition, useSequential, seq));
|
||||
|
||||
if (parsed.isEmpty())
|
||||
return;
|
||||
@@ -210,6 +176,16 @@ void TrackListModel::removeTrack(int row)
|
||||
endRemoveRows();
|
||||
}
|
||||
|
||||
void TrackListModel::notifyFavChanged(qint64 id)
|
||||
{
|
||||
for (int r = 0; r < m_tracks.size(); ++r) {
|
||||
if (m_tracks[r].id == id) {
|
||||
const auto idx = index(r, ColTitle);
|
||||
emit dataChanged(idx, idx, {Qt::DecorationRole});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void TrackListModel::setFavIds(const QSet<qint64> &ids)
|
||||
{
|
||||
m_favIds = ids;
|
||||
@@ -221,23 +197,13 @@ void TrackListModel::setFavIds(const QSet<qint64> &ids)
|
||||
void TrackListModel::addFavId(qint64 id)
|
||||
{
|
||||
m_favIds.insert(id);
|
||||
for (int r = 0; r < m_tracks.size(); ++r) {
|
||||
if (m_tracks[r].id == id) {
|
||||
const auto idx = index(r, ColTitle);
|
||||
emit dataChanged(idx, idx, {Qt::DecorationRole});
|
||||
}
|
||||
}
|
||||
notifyFavChanged(id);
|
||||
}
|
||||
|
||||
void TrackListModel::removeFavId(qint64 id)
|
||||
{
|
||||
m_favIds.remove(id);
|
||||
for (int r = 0; r < m_tracks.size(); ++r) {
|
||||
if (m_tracks[r].id == id) {
|
||||
const auto idx = index(r, ColTitle);
|
||||
emit dataChanged(idx, idx, {Qt::DecorationRole});
|
||||
}
|
||||
}
|
||||
notifyFavChanged(id);
|
||||
}
|
||||
|
||||
void TrackListModel::setPlayingId(qint64 id)
|
||||
@@ -290,7 +256,7 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
|
||||
QFont f; f.setBold(true); return f;
|
||||
}
|
||||
if (role == Qt::ForegroundRole)
|
||||
return QColor(0xFF, 0xB2, 0x32);
|
||||
return Colors::QobuzOrange;
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -313,8 +279,8 @@ QVariant TrackListModel::data(const QModelIndex &index, int role) const
|
||||
}
|
||||
|
||||
if (role == Qt::ForegroundRole) {
|
||||
if (!t.streamable) return QColor(0x55, 0x55, 0x55);
|
||||
if (isPlaying) return QColor(0xFF, 0xB2, 0x32); // Qobuz orange
|
||||
if (!t.streamable) return Colors::DisabledText;
|
||||
if (isPlaying) return Colors::QobuzOrange;
|
||||
}
|
||||
|
||||
if (role == Qt::DecorationRole && index.column() == ColNumber && isPlaying) {
|
||||
|
||||
@@ -105,4 +105,10 @@ private:
|
||||
|
||||
// Sort m_tracks in-place without emitting any signals.
|
||||
void sortData(int column, Qt::SortOrder order);
|
||||
|
||||
// Parse a single JSON track object into a TrackItem.
|
||||
static TrackItem parseTrackItem(const QJsonObject &t, bool usePosition, bool useSequential, int &seq);
|
||||
|
||||
// Emit dataChanged(DecorationRole) for all rows matching id.
|
||||
void notifyFavChanged(qint64 id);
|
||||
};
|
||||
|
||||
238
src/playqueue.cpp
Normal file
238
src/playqueue.cpp
Normal file
@@ -0,0 +1,238 @@
|
||||
#include "playqueue.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <random>
|
||||
|
||||
PlayQueue::PlayQueue(QObject *parent) : QObject(parent) {}
|
||||
|
||||
void PlayQueue::setContext(const QJsonArray &tracks, int startIndex)
|
||||
{
|
||||
m_queue.clear();
|
||||
m_playNext.clear();
|
||||
|
||||
// Only queue streamable tracks; find the filtered index for startIndex
|
||||
int filteredStart = 0;
|
||||
int filteredIdx = 0;
|
||||
bool found = false;
|
||||
for (int orig = 0; orig < tracks.size(); ++orig) {
|
||||
const QJsonObject t = tracks[orig].toObject();
|
||||
if (!t["streamable"].toBool(true))
|
||||
continue;
|
||||
if (!found && orig >= startIndex) {
|
||||
filteredStart = filteredIdx;
|
||||
found = true;
|
||||
}
|
||||
m_queue.append(t);
|
||||
++filteredIdx;
|
||||
}
|
||||
m_index = qBound(0, filteredStart, qMax(0, m_queue.size() - 1));
|
||||
|
||||
if (m_shuffle)
|
||||
shuffleQueue(m_index);
|
||||
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
void PlayQueue::reorderContext(const QJsonArray &tracks, qint64 currentId)
|
||||
{
|
||||
m_queue.clear();
|
||||
for (const auto &v : tracks) {
|
||||
const QJsonObject t = v.toObject();
|
||||
if (t["streamable"].toBool(true))
|
||||
m_queue.append(t);
|
||||
}
|
||||
|
||||
m_index = 0;
|
||||
for (int i = 0; i < m_queue.size(); ++i) {
|
||||
if (static_cast<qint64>(m_queue[i]["id"].toDouble()) == currentId) {
|
||||
m_index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
void PlayQueue::clearUpcoming()
|
||||
{
|
||||
m_playNext.clear();
|
||||
if (m_index < m_queue.size())
|
||||
m_queue.resize(m_index + 1); // keep up to and including current
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
void PlayQueue::removeUpcoming(int upcomingIndex)
|
||||
{
|
||||
if (upcomingIndex < m_playNext.size()) {
|
||||
m_playNext.removeAt(upcomingIndex);
|
||||
} else {
|
||||
const int queueIdx = m_index + 1 + (upcomingIndex - m_playNext.size());
|
||||
if (queueIdx < m_queue.size())
|
||||
m_queue.removeAt(queueIdx);
|
||||
}
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
void PlayQueue::setShuffle(bool enabled)
|
||||
{
|
||||
if (m_shuffle == enabled) return;
|
||||
m_shuffle = enabled;
|
||||
if (enabled && !m_queue.isEmpty())
|
||||
shuffleQueue(m_index);
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
void PlayQueue::shuffleNow()
|
||||
{
|
||||
if (m_queue.isEmpty()) return;
|
||||
shuffleQueue(m_index);
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
void PlayQueue::addToQueue(const QJsonObject &track)
|
||||
{
|
||||
m_playNext.append(track);
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
void PlayQueue::playNext(const QJsonObject &track)
|
||||
{
|
||||
m_playNext.prepend(track);
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
bool PlayQueue::hasCurrent() const
|
||||
{
|
||||
return (!m_playNext.isEmpty()) || (!m_queue.isEmpty());
|
||||
}
|
||||
|
||||
QJsonObject PlayQueue::current() const
|
||||
{
|
||||
if (!m_playNext.isEmpty()) return m_playNext.first();
|
||||
if (m_index < m_queue.size()) return m_queue.at(m_index);
|
||||
return {};
|
||||
}
|
||||
|
||||
qint64 PlayQueue::currentId() const
|
||||
{
|
||||
return static_cast<qint64>(current()["id"].toDouble());
|
||||
}
|
||||
|
||||
QJsonObject PlayQueue::advance()
|
||||
{
|
||||
if (!m_playNext.isEmpty()) {
|
||||
// Return the playNext item directly — do NOT call current() after
|
||||
// removal, as that would fall back to the already-playing m_index track.
|
||||
const QJsonObject next = m_playNext.takeFirst();
|
||||
emit queueChanged();
|
||||
return next;
|
||||
}
|
||||
++m_index;
|
||||
emit queueChanged();
|
||||
return current();
|
||||
}
|
||||
|
||||
QJsonObject PlayQueue::stepBack()
|
||||
{
|
||||
if (m_index > 0) --m_index;
|
||||
emit queueChanged();
|
||||
return current();
|
||||
}
|
||||
|
||||
bool PlayQueue::canGoNext() const
|
||||
{
|
||||
return !m_playNext.isEmpty() || (m_index + 1 < m_queue.size());
|
||||
}
|
||||
|
||||
void PlayQueue::setCurrentById(qint64 id)
|
||||
{
|
||||
m_playNext.clear();
|
||||
for (int i = 0; i < m_queue.size(); ++i) {
|
||||
if (static_cast<qint64>(m_queue[i]["id"].toDouble()) == id) {
|
||||
m_index = i;
|
||||
emit queueChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QVector<QJsonObject> PlayQueue::upcomingTracks(int maxCount) const
|
||||
{
|
||||
QVector<QJsonObject> result;
|
||||
result.append(m_playNext);
|
||||
for (int i = m_index + 1; i < m_queue.size() && result.size() < maxCount; ++i)
|
||||
result.append(m_queue.at(i));
|
||||
return result;
|
||||
}
|
||||
|
||||
QJsonObject PlayQueue::skipToUpcoming(int upcomingIndex)
|
||||
{
|
||||
// Remove items 0..upcomingIndex-1 from the front of upcoming
|
||||
for (int i = 0; i < upcomingIndex; ++i) {
|
||||
if (!m_playNext.isEmpty())
|
||||
m_playNext.removeFirst();
|
||||
else if (m_index + 1 < m_queue.size())
|
||||
++m_index;
|
||||
}
|
||||
// Pop and return the target (now at upcoming[0])
|
||||
if (!m_playNext.isEmpty()) {
|
||||
const QJsonObject t = m_playNext.takeFirst();
|
||||
emit queueChanged();
|
||||
return t;
|
||||
}
|
||||
if (m_index + 1 < m_queue.size()) {
|
||||
++m_index;
|
||||
emit queueChanged();
|
||||
return m_queue.at(m_index);
|
||||
}
|
||||
emit queueChanged();
|
||||
return {};
|
||||
}
|
||||
|
||||
void PlayQueue::setUpcomingOrder(const QVector<QJsonObject> &newOrder)
|
||||
{
|
||||
m_playNext = newOrder;
|
||||
m_queue.resize(m_index + 1); // drop old main-queue tail
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
void PlayQueue::appendToContext(const QJsonArray &tracks)
|
||||
{
|
||||
for (const auto &v : tracks) {
|
||||
const QJsonObject t = v.toObject();
|
||||
if (t["streamable"].toBool(true))
|
||||
m_queue.append(t);
|
||||
}
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
void PlayQueue::moveUpcomingToTop(int upcomingIndex)
|
||||
{
|
||||
if (upcomingIndex < 0) return;
|
||||
QJsonObject track;
|
||||
if (upcomingIndex < m_playNext.size()) {
|
||||
if (upcomingIndex == 0) return; // already at top
|
||||
track = m_playNext.takeAt(upcomingIndex);
|
||||
} else {
|
||||
const int queueIdx = m_index + 1 + (upcomingIndex - m_playNext.size());
|
||||
if (queueIdx >= m_queue.size()) return;
|
||||
track = m_queue.takeAt(queueIdx);
|
||||
}
|
||||
m_playNext.prepend(track);
|
||||
emit queueChanged();
|
||||
}
|
||||
|
||||
void PlayQueue::shuffleQueue(int keepAtFront)
|
||||
{
|
||||
if (m_queue.isEmpty()) return;
|
||||
// Keep the current track at index 0 of the remaining queue
|
||||
if (keepAtFront >= 0 && keepAtFront < m_queue.size()) {
|
||||
QJsonObject current = m_queue.takeAt(keepAtFront);
|
||||
std::mt19937 rng(std::random_device{}());
|
||||
std::shuffle(m_queue.begin(), m_queue.end(), rng);
|
||||
m_queue.prepend(current);
|
||||
} else {
|
||||
std::mt19937 rng(std::random_device{}());
|
||||
std::shuffle(m_queue.begin(), m_queue.end(), rng);
|
||||
}
|
||||
m_index = 0;
|
||||
}
|
||||
@@ -4,8 +4,6 @@
|
||||
#include <QVector>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <algorithm>
|
||||
#include <random>
|
||||
|
||||
/// Local playback queue. Holds the ordered list of tracks for the current
|
||||
/// context (album / playlist / search result / favourites) plus a separate
|
||||
@@ -15,259 +13,83 @@ class PlayQueue : public QObject
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit PlayQueue(QObject *parent = nullptr) : QObject(parent) {}
|
||||
explicit PlayQueue(QObject *parent = nullptr);
|
||||
|
||||
// ---- Loading a new context ----
|
||||
|
||||
/// Replace the queue with all tracks from an album/playlist JSON context.
|
||||
/// @param startIndex Index of the track to start playing (-1 = first).
|
||||
void setContext(const QJsonArray &tracks, int startIndex = 0)
|
||||
{
|
||||
m_queue.clear();
|
||||
m_playNext.clear();
|
||||
|
||||
// Only queue streamable tracks; find the filtered index for startIndex
|
||||
int filteredStart = 0;
|
||||
int filteredIdx = 0;
|
||||
bool found = false;
|
||||
for (int orig = 0; orig < tracks.size(); ++orig) {
|
||||
const QJsonObject t = tracks[orig].toObject();
|
||||
if (!t["streamable"].toBool(true))
|
||||
continue;
|
||||
if (!found && orig >= startIndex) {
|
||||
filteredStart = filteredIdx;
|
||||
found = true;
|
||||
}
|
||||
m_queue.append(t);
|
||||
++filteredIdx;
|
||||
}
|
||||
m_index = qBound(0, filteredStart, qMax(0, m_queue.size() - 1));
|
||||
|
||||
if (m_shuffle)
|
||||
shuffleQueue(m_index);
|
||||
|
||||
emit queueChanged();
|
||||
}
|
||||
void setContext(const QJsonArray &tracks, int startIndex = 0);
|
||||
|
||||
// ---- Re-order after a sort (keeps m_playNext, updates m_index) ----
|
||||
|
||||
void reorderContext(const QJsonArray &tracks, qint64 currentId)
|
||||
{
|
||||
m_queue.clear();
|
||||
for (const auto &v : tracks) {
|
||||
const QJsonObject t = v.toObject();
|
||||
if (t["streamable"].toBool(true))
|
||||
m_queue.append(t);
|
||||
}
|
||||
|
||||
m_index = 0;
|
||||
for (int i = 0; i < m_queue.size(); ++i) {
|
||||
if (static_cast<qint64>(m_queue[i]["id"].toDouble()) == currentId) {
|
||||
m_index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
emit queueChanged();
|
||||
}
|
||||
void reorderContext(const QJsonArray &tracks, qint64 currentId);
|
||||
|
||||
// ---- Clear / remove upcoming ----
|
||||
|
||||
/// Remove all "up next" entries (playNext + remaining main queue after current).
|
||||
void clearUpcoming()
|
||||
{
|
||||
m_playNext.clear();
|
||||
if (m_index < m_queue.size())
|
||||
m_queue.resize(m_index + 1); // keep up to and including current
|
||||
emit queueChanged();
|
||||
}
|
||||
void clearUpcoming();
|
||||
|
||||
/// Remove one upcoming track by its index in upcomingTracks().
|
||||
void removeUpcoming(int upcomingIndex)
|
||||
{
|
||||
if (upcomingIndex < m_playNext.size()) {
|
||||
m_playNext.removeAt(upcomingIndex);
|
||||
} else {
|
||||
const int queueIdx = m_index + 1 + (upcomingIndex - m_playNext.size());
|
||||
if (queueIdx < m_queue.size())
|
||||
m_queue.removeAt(queueIdx);
|
||||
}
|
||||
emit queueChanged();
|
||||
}
|
||||
void removeUpcoming(int upcomingIndex);
|
||||
|
||||
// ---- Shuffle ----
|
||||
|
||||
bool shuffleEnabled() const { return m_shuffle; }
|
||||
|
||||
void setShuffle(bool enabled)
|
||||
{
|
||||
if (m_shuffle == enabled) return;
|
||||
m_shuffle = enabled;
|
||||
if (enabled && !m_queue.isEmpty())
|
||||
shuffleQueue(m_index);
|
||||
emit queueChanged();
|
||||
}
|
||||
void setShuffle(bool enabled);
|
||||
|
||||
/// Shuffle the current queue once without changing the global shuffle flag.
|
||||
void shuffleNow()
|
||||
{
|
||||
if (m_queue.isEmpty()) return;
|
||||
shuffleQueue(m_index);
|
||||
emit queueChanged();
|
||||
}
|
||||
void shuffleNow();
|
||||
|
||||
// ---- Play-next prepend queue (like "Add to queue" ----
|
||||
|
||||
void addToQueue(const QJsonObject &track)
|
||||
{
|
||||
m_playNext.append(track);
|
||||
emit queueChanged();
|
||||
}
|
||||
void addToQueue(const QJsonObject &track);
|
||||
|
||||
void playNext(const QJsonObject &track)
|
||||
{
|
||||
m_playNext.prepend(track);
|
||||
emit queueChanged();
|
||||
}
|
||||
void playNext(const QJsonObject &track);
|
||||
|
||||
// ---- Navigation ----
|
||||
|
||||
bool hasCurrent() const
|
||||
{
|
||||
return (!m_playNext.isEmpty()) || (!m_queue.isEmpty());
|
||||
}
|
||||
bool hasCurrent() const;
|
||||
|
||||
QJsonObject current() const
|
||||
{
|
||||
if (!m_playNext.isEmpty()) return m_playNext.first();
|
||||
if (m_index < m_queue.size()) return m_queue.at(m_index);
|
||||
return {};
|
||||
}
|
||||
QJsonObject current() const;
|
||||
|
||||
qint64 currentId() const
|
||||
{
|
||||
return static_cast<qint64>(current()["id"].toDouble());
|
||||
}
|
||||
qint64 currentId() const;
|
||||
|
||||
/// Advance and return the track to play next. Returns {} at end of queue.
|
||||
QJsonObject advance()
|
||||
{
|
||||
if (!m_playNext.isEmpty()) {
|
||||
// Return the playNext item directly — do NOT call current() after
|
||||
// removal, as that would fall back to the already-playing m_index track.
|
||||
const QJsonObject next = m_playNext.takeFirst();
|
||||
emit queueChanged();
|
||||
return next;
|
||||
}
|
||||
++m_index;
|
||||
emit queueChanged();
|
||||
return current();
|
||||
}
|
||||
QJsonObject advance();
|
||||
|
||||
/// Step backwards in the main queue (play-next is not affected).
|
||||
QJsonObject stepBack()
|
||||
{
|
||||
if (m_index > 0) --m_index;
|
||||
emit queueChanged();
|
||||
return current();
|
||||
}
|
||||
QJsonObject stepBack();
|
||||
|
||||
bool canGoNext() const
|
||||
{
|
||||
return !m_playNext.isEmpty() || (m_index + 1 < m_queue.size());
|
||||
}
|
||||
bool canGoNext() const;
|
||||
|
||||
bool canGoPrev() const { return m_index > 0; }
|
||||
|
||||
// ---- Index lookup ----
|
||||
|
||||
/// Set the current position by track id (after user double-clicks a row).
|
||||
void setCurrentById(qint64 id)
|
||||
{
|
||||
m_playNext.clear();
|
||||
for (int i = 0; i < m_queue.size(); ++i) {
|
||||
if (static_cast<qint64>(m_queue[i]["id"].toDouble()) == id) {
|
||||
m_index = i;
|
||||
emit queueChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
void setCurrentById(qint64 id);
|
||||
|
||||
// ---- Accessors for queue panel ----
|
||||
|
||||
QVector<QJsonObject> upcomingTracks(int maxCount = 200) const
|
||||
{
|
||||
QVector<QJsonObject> result;
|
||||
result.append(m_playNext);
|
||||
for (int i = m_index + 1; i < m_queue.size() && result.size() < maxCount; ++i)
|
||||
result.append(m_queue.at(i));
|
||||
return result;
|
||||
}
|
||||
QVector<QJsonObject> upcomingTracks(int maxCount = 200) const;
|
||||
|
||||
int playNextCount() const { return m_playNext.size(); }
|
||||
int totalSize() const { return m_playNext.size() + m_queue.size(); }
|
||||
int currentIndex() const { return m_index; }
|
||||
|
||||
/// Skip to upcoming[upcomingIndex]: removes everything before it, pops and returns it.
|
||||
QJsonObject skipToUpcoming(int upcomingIndex)
|
||||
{
|
||||
// Remove items 0..upcomingIndex-1 from the front of upcoming
|
||||
for (int i = 0; i < upcomingIndex; ++i) {
|
||||
if (!m_playNext.isEmpty())
|
||||
m_playNext.removeFirst();
|
||||
else if (m_index + 1 < m_queue.size())
|
||||
++m_index;
|
||||
}
|
||||
// Pop and return the target (now at upcoming[0])
|
||||
if (!m_playNext.isEmpty()) {
|
||||
const QJsonObject t = m_playNext.takeFirst();
|
||||
emit queueChanged();
|
||||
return t;
|
||||
}
|
||||
if (m_index + 1 < m_queue.size()) {
|
||||
++m_index;
|
||||
emit queueChanged();
|
||||
return m_queue.at(m_index);
|
||||
}
|
||||
emit queueChanged();
|
||||
return {};
|
||||
}
|
||||
QJsonObject skipToUpcoming(int upcomingIndex);
|
||||
|
||||
/// Replace the upcoming list with a new order (used after drag-reorder in UI).
|
||||
void setUpcomingOrder(const QVector<QJsonObject> &newOrder)
|
||||
{
|
||||
m_playNext = newOrder;
|
||||
m_queue.resize(m_index + 1); // drop old main-queue tail
|
||||
emit queueChanged();
|
||||
}
|
||||
void setUpcomingOrder(const QVector<QJsonObject> &newOrder);
|
||||
|
||||
/// Append tracks to the main queue tail (autoplay/discovery).
|
||||
void appendToContext(const QJsonArray &tracks)
|
||||
{
|
||||
for (const auto &v : tracks) {
|
||||
const QJsonObject t = v.toObject();
|
||||
if (t["streamable"].toBool(true))
|
||||
m_queue.append(t);
|
||||
}
|
||||
emit queueChanged();
|
||||
}
|
||||
void appendToContext(const QJsonArray &tracks);
|
||||
|
||||
/// Move an upcoming item (by its index in upcomingTracks()) to the front of playNext.
|
||||
void moveUpcomingToTop(int upcomingIndex)
|
||||
{
|
||||
if (upcomingIndex < 0) return;
|
||||
QJsonObject track;
|
||||
if (upcomingIndex < m_playNext.size()) {
|
||||
if (upcomingIndex == 0) return; // already at top
|
||||
track = m_playNext.takeAt(upcomingIndex);
|
||||
} else {
|
||||
const int queueIdx = m_index + 1 + (upcomingIndex - m_playNext.size());
|
||||
if (queueIdx >= m_queue.size()) return;
|
||||
track = m_queue.takeAt(queueIdx);
|
||||
}
|
||||
m_playNext.prepend(track);
|
||||
emit queueChanged();
|
||||
}
|
||||
void moveUpcomingToTop(int upcomingIndex);
|
||||
|
||||
signals:
|
||||
void queueChanged();
|
||||
@@ -278,19 +100,5 @@ private:
|
||||
int m_index = 0;
|
||||
bool m_shuffle = false;
|
||||
|
||||
void shuffleQueue(int keepAtFront)
|
||||
{
|
||||
if (m_queue.isEmpty()) return;
|
||||
// Keep the current track at index 0 of the remaining queue
|
||||
if (keepAtFront >= 0 && keepAtFront < m_queue.size()) {
|
||||
QJsonObject current = m_queue.takeAt(keepAtFront);
|
||||
std::mt19937 rng(std::random_device{}());
|
||||
std::shuffle(m_queue.begin(), m_queue.end(), rng);
|
||||
m_queue.prepend(current);
|
||||
} else {
|
||||
std::mt19937 rng(std::random_device{}());
|
||||
std::shuffle(m_queue.begin(), m_queue.end(), rng);
|
||||
}
|
||||
m_index = 0;
|
||||
}
|
||||
void shuffleQueue(int keepAtFront);
|
||||
};
|
||||
|
||||
32
src/util/colors.hpp
Normal file
32
src/util/colors.hpp
Normal file
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <QColor>
|
||||
|
||||
namespace Colors {
|
||||
|
||||
// Brand accents
|
||||
inline const QColor QobuzOrange{0xFF, 0xB2, 0x32};
|
||||
inline const QColor QobuzBlue {0x46, 0xB3, 0xEE};
|
||||
|
||||
// Badge / indicator colors used in tree-view item foregrounds
|
||||
inline const QColor BadgeGreen {QStringLiteral("#2FA84F")};
|
||||
inline const QColor BadgeBlue {QStringLiteral("#2B7CD3")};
|
||||
inline const QColor BadgeGray {QStringLiteral("#8E8E93")};
|
||||
|
||||
// Text
|
||||
inline const QColor LightText {0xe8, 0xe8, 0xe8};
|
||||
inline const QColor SubduedText {0xaa, 0xaa, 0xaa};
|
||||
inline const QColor PlaceholderText{0x66, 0x66, 0x66};
|
||||
inline const QColor DisabledText {0x55, 0x55, 0x55};
|
||||
|
||||
// Surfaces / backgrounds
|
||||
inline const QColor WindowBg {0x19, 0x19, 0x19};
|
||||
inline const QColor BaseBg {0x14, 0x14, 0x14};
|
||||
inline const QColor AlternateBaseBg{0x1e, 0x1e, 0x1e};
|
||||
inline const QColor ButtonSurface {0x2a, 0x2a, 0x2a};
|
||||
inline const QColor ContextBg {0x1a, 0x1a, 0x1a};
|
||||
inline const QColor MidSurface {0x2f, 0x2f, 0x2f};
|
||||
inline const QColor DarkSurface {0x0e, 0x0e, 0x0e};
|
||||
inline const QColor HighlightedFg {0x10, 0x10, 0x10};
|
||||
|
||||
} // namespace Colors
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "../util/colors.hpp"
|
||||
|
||||
#include <QTreeWidget>
|
||||
#include <QTreeWidgetItem>
|
||||
#include <QHeaderView>
|
||||
@@ -89,7 +91,7 @@ public:
|
||||
auto *item = new QTreeWidgetItem(this);
|
||||
if (hiRes) {
|
||||
item->setText(0, QStringLiteral("H"));
|
||||
item->setForeground(0, QColor(QStringLiteral("#FFB232")));
|
||||
item->setForeground(0, Colors::QobuzOrange);
|
||||
item->setFont(0, hiResFont);
|
||||
item->setTextAlignment(0, Qt::AlignCenter);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "../../backend/qobuzbackend.hpp"
|
||||
#include "../../util/colors.hpp"
|
||||
|
||||
#include <QDockWidget>
|
||||
#include <QWidget>
|
||||
@@ -32,11 +33,11 @@ namespace Context
|
||||
{
|
||||
QPainter p(this);
|
||||
if (m_pix.isNull()) {
|
||||
p.fillRect(rect(), QColor(0x1a, 0x1a, 0x1a));
|
||||
p.fillRect(rect(), Colors::ContextBg);
|
||||
return;
|
||||
}
|
||||
const QPixmap scaled = m_pix.scaled(size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
|
||||
p.fillRect(rect(), QColor(0x1a, 0x1a, 0x1a));
|
||||
p.fillRect(rect(), Colors::ContextBg);
|
||||
p.drawPixmap((width() - scaled.width()) / 2,
|
||||
(height() - scaled.height()) / 2,
|
||||
scaled);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "genrebrowser.hpp"
|
||||
#include "../util/colors.hpp"
|
||||
|
||||
#include <QAction>
|
||||
#include <QDialog>
|
||||
@@ -9,7 +10,9 @@
|
||||
#include <QListWidget>
|
||||
#include <QMenu>
|
||||
#include <QPushButton>
|
||||
#include <QScrollBar>
|
||||
#include <QSignalBlocker>
|
||||
#include <QTimer>
|
||||
#include <QTreeWidgetItem>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
@@ -120,8 +123,18 @@ GenreBrowserView::GenreBrowserView(QobuzBackend *backend, PlayQueue *queue, QWid
|
||||
m_playlistList->header()->setSectionResizeMode(3, QHeaderView::ResizeToContents);
|
||||
m_playlistList->header()->setStretchLastSection(false);
|
||||
|
||||
auto *playlistPage = new QWidget(this);
|
||||
auto *playlistPageLayout = new QVBoxLayout(playlistPage);
|
||||
playlistPageLayout->setContentsMargins(0, 0, 0, 0);
|
||||
playlistPageLayout->setSpacing(0);
|
||||
playlistPageLayout->addWidget(m_playlistList, 1);
|
||||
|
||||
m_loadMorePlaylistsBtn = new QPushButton(tr("Load more playlists…"), this);
|
||||
m_loadMorePlaylistsBtn->hide();
|
||||
playlistPageLayout->addWidget(m_loadMorePlaylistsBtn);
|
||||
|
||||
m_resultsStack->addWidget(m_albumList);
|
||||
m_resultsStack->addWidget(m_playlistList);
|
||||
m_resultsStack->addWidget(playlistPage);
|
||||
layout->addWidget(m_resultsStack, 1);
|
||||
|
||||
connect(m_backend, &QobuzBackend::genresLoaded,
|
||||
@@ -180,6 +193,12 @@ GenreBrowserView::GenreBrowserView(QobuzBackend *backend, PlayQueue *queue, QWid
|
||||
this, &GenreBrowserView::onAlbumScroll);
|
||||
connect(m_playlistList->verticalScrollBar(), &QScrollBar::valueChanged,
|
||||
this, &GenreBrowserView::onPlaylistScroll);
|
||||
connect(m_loadMorePlaylistsBtn, &QPushButton::clicked, this, [this] {
|
||||
m_loadMorePlaylistsBtn->hide();
|
||||
requestPlaylistsPage(m_lastPlaylistGenreIds, m_lastPlaylistType,
|
||||
m_lastPlaylistTags, m_lastPlaylistQuery,
|
||||
m_playlistOffset, true);
|
||||
});
|
||||
|
||||
m_kindCombo->setCurrentIndex(0);
|
||||
refreshModeUi();
|
||||
@@ -391,9 +410,12 @@ void GenreBrowserView::onFeaturedAlbumsLoaded(const QJsonObject &result)
|
||||
}
|
||||
|
||||
// If the viewport is not scrollable yet, eagerly fetch more pages.
|
||||
QScrollBar *bar = m_albumList->verticalScrollBar();
|
||||
if (bar && bar->maximum() == 0 && m_albumOffset < m_albumTotal)
|
||||
requestAlbumsPage(m_lastAlbumGenreIds, m_lastAlbumType, m_albumOffset, true);
|
||||
// Deferred: the scrollbar maximum isn't updated until after layout runs.
|
||||
QTimer::singleShot(0, this, [this] {
|
||||
QScrollBar *bar = m_albumList->verticalScrollBar();
|
||||
if (bar && bar->maximum() == 0 && m_albumOffset < m_albumTotal)
|
||||
requestAlbumsPage(m_lastAlbumGenreIds, m_lastAlbumType, m_albumOffset, true);
|
||||
});
|
||||
}
|
||||
|
||||
void GenreBrowserView::onFeaturedPlaylistsLoaded(const QJsonObject &result)
|
||||
@@ -413,9 +435,11 @@ void GenreBrowserView::onFeaturedPlaylistsLoaded(const QJsonObject &result)
|
||||
m_playlistTotal = m_playlistOffset;
|
||||
m_loadingPlaylists = false;
|
||||
|
||||
QScrollBar *bar = m_playlistList->verticalScrollBar();
|
||||
if (bar && bar->maximum() == 0 && m_playlistOffset < m_playlistTotal)
|
||||
requestPlaylistsPage(m_lastPlaylistGenreIds, m_lastPlaylistType, m_lastPlaylistTags, m_lastPlaylistQuery, m_playlistOffset, true);
|
||||
QTimer::singleShot(0, this, [this] {
|
||||
QScrollBar *bar = m_playlistList->verticalScrollBar();
|
||||
if (bar && bar->maximum() == 0 && m_playlistOffset < m_playlistTotal)
|
||||
requestPlaylistsPage(m_lastPlaylistGenreIds, m_lastPlaylistType, m_lastPlaylistTags, m_lastPlaylistQuery, m_playlistOffset, true);
|
||||
});
|
||||
}
|
||||
|
||||
void GenreBrowserView::onDiscoverPlaylistsLoaded(const QJsonObject &result)
|
||||
@@ -435,9 +459,11 @@ void GenreBrowserView::onDiscoverPlaylistsLoaded(const QJsonObject &result)
|
||||
m_playlistTotal = m_playlistOffset;
|
||||
m_loadingPlaylists = false;
|
||||
|
||||
QScrollBar *bar = m_playlistList->verticalScrollBar();
|
||||
if (bar && bar->maximum() == 0 && m_playlistOffset < m_playlistTotal)
|
||||
requestPlaylistsPage(m_lastPlaylistGenreIds, m_lastPlaylistType, m_lastPlaylistTags, m_lastPlaylistQuery, m_playlistOffset, true);
|
||||
QTimer::singleShot(0, this, [this] {
|
||||
QScrollBar *bar = m_playlistList->verticalScrollBar();
|
||||
if (bar && bar->maximum() == 0 && m_playlistOffset < m_playlistTotal)
|
||||
requestPlaylistsPage(m_lastPlaylistGenreIds, m_lastPlaylistType, m_lastPlaylistTags, m_lastPlaylistQuery, m_playlistOffset, true);
|
||||
});
|
||||
}
|
||||
|
||||
void GenreBrowserView::onPlaylistSearchLoaded(const QJsonObject &result)
|
||||
@@ -456,9 +482,25 @@ void GenreBrowserView::onPlaylistSearchLoaded(const QJsonObject &result)
|
||||
m_playlistTotal = m_playlistOffset;
|
||||
m_loadingPlaylists = false;
|
||||
|
||||
QScrollBar *bar = m_playlistList->verticalScrollBar();
|
||||
if (bar && bar->maximum() == 0 && m_playlistOffset < m_playlistTotal)
|
||||
requestPlaylistsPage(m_lastPlaylistGenreIds, m_lastPlaylistType, m_lastPlaylistTags, m_lastPlaylistQuery, m_playlistOffset, true);
|
||||
// Eagerly fill the viewport, then switch to a manual "Load more" button.
|
||||
if (m_playlistOffset >= m_playlistTotal) {
|
||||
m_loadMorePlaylistsBtn->hide();
|
||||
m_searchViewportFilled = true;
|
||||
} else if (!m_searchViewportFilled) {
|
||||
QTimer::singleShot(0, this, [this] {
|
||||
QScrollBar *bar = m_playlistList->verticalScrollBar();
|
||||
if (bar && bar->maximum() == 0 && m_playlistOffset < m_playlistTotal) {
|
||||
requestPlaylistsPage(m_lastPlaylistGenreIds, m_lastPlaylistType,
|
||||
m_lastPlaylistTags, m_lastPlaylistQuery,
|
||||
m_playlistOffset, true);
|
||||
} else {
|
||||
m_searchViewportFilled = true;
|
||||
m_loadMorePlaylistsBtn->setVisible(m_playlistOffset < m_playlistTotal);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
m_loadMorePlaylistsBtn->setVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
void GenreBrowserView::onSelectionChanged()
|
||||
@@ -571,6 +613,9 @@ void GenreBrowserView::requestPlaylistsPage(const QString &genreIds, const QStri
|
||||
m_loadingPlaylists = false;
|
||||
m_playlistOffset = 0;
|
||||
m_playlistTotal = 0;
|
||||
m_loadMorePlaylistsBtn->hide();
|
||||
if (type == QStringLiteral("search"))
|
||||
m_searchViewportFilled = false;
|
||||
}
|
||||
|
||||
m_lastPlaylistGenreIds = genreIds;
|
||||
@@ -580,7 +625,7 @@ void GenreBrowserView::requestPlaylistsPage(const QString &genreIds, const QStri
|
||||
m_loadingPlaylists = true;
|
||||
|
||||
if (type == QStringLiteral("search")) {
|
||||
m_backend->searchPlaylists(query, 8, static_cast<quint32>(offset));
|
||||
m_backend->searchPlaylists(query, 25, static_cast<quint32>(offset));
|
||||
} else if (type.startsWith(QStringLiteral("discover-"))) {
|
||||
m_backend->discoverPlaylists(genreIds, tags, 25, static_cast<quint32>(offset));
|
||||
} else {
|
||||
@@ -608,6 +653,9 @@ void GenreBrowserView::onAlbumScroll(int value)
|
||||
|
||||
void GenreBrowserView::onPlaylistScroll(int value)
|
||||
{
|
||||
// Search results use a manual "Load more" button instead of infinite scroll.
|
||||
if (m_lastPlaylistType == QStringLiteral("search"))
|
||||
return;
|
||||
if (m_loadingPlaylists)
|
||||
return;
|
||||
if (m_playlistOffset >= m_playlistTotal)
|
||||
@@ -675,16 +723,28 @@ void GenreBrowserView::onAlbumContextMenu(const QPoint &pos)
|
||||
|
||||
const QString albumId = item->data(1, Qt::UserRole).toString();
|
||||
const qint64 artistId = item->data(2, Qt::UserRole).toLongLong();
|
||||
const QString albumTitle = item->text(1);
|
||||
const QString artistName = item->text(2);
|
||||
|
||||
QMenu menu(this);
|
||||
|
||||
auto *openAlbum = menu.addAction(tr("Open Album"));
|
||||
auto *openAlbum = menu.addAction(
|
||||
QIcon(":/res/icons/view-media-album-cover.svg"),
|
||||
tr("Open album: %1").arg(QString(albumTitle).replace(QLatin1Char('&'), QStringLiteral("&&"))));
|
||||
connect(openAlbum, &QAction::triggered, this, [this, albumId] {
|
||||
emit albumSelected(albumId);
|
||||
});
|
||||
|
||||
auto *addFav = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites"));
|
||||
connect(addFav, &QAction::triggered, this, [this, albumId] {
|
||||
m_backend->addFavAlbum(albumId);
|
||||
});
|
||||
|
||||
if (artistId > 0) {
|
||||
auto *openArtist = menu.addAction(tr("Open Artist"));
|
||||
menu.addSeparator();
|
||||
auto *openArtist = menu.addAction(
|
||||
QIcon(":/res/icons/view-media-artist.svg"),
|
||||
tr("Open artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&"))));
|
||||
connect(openArtist, &QAction::triggered, this, [this, artistId] {
|
||||
emit artistSelected(artistId);
|
||||
});
|
||||
@@ -714,7 +774,8 @@ void GenreBrowserView::onPlaylistContextMenu(const QPoint &pos)
|
||||
return;
|
||||
|
||||
QMenu menu(this);
|
||||
auto *openPlaylist = menu.addAction(tr("Open Playlist"));
|
||||
auto *openPlaylist = menu.addAction(
|
||||
QIcon(":/res/icons/view-media-playlist.svg"), tr("Open playlist"));
|
||||
connect(openPlaylist, &QAction::triggered, this, [this, playlistId] {
|
||||
emit playlistSelected(playlistId);
|
||||
});
|
||||
@@ -740,7 +801,7 @@ void GenreBrowserView::setPlaylistItems(const QJsonArray &items, bool append)
|
||||
auto *item = new QTreeWidgetItem(m_playlistList,
|
||||
QStringList{QStringLiteral("P"), name, owner, tracksCount > 0 ? QString::number(tracksCount) : QString()});
|
||||
item->setData(0, Qt::UserRole, playlistId);
|
||||
item->setForeground(0, QColor(QStringLiteral("#2B7CD3")));
|
||||
item->setForeground(0, Colors::BadgeBlue);
|
||||
item->setFont(0, tagFont);
|
||||
item->setTextAlignment(0, Qt::AlignCenter);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ private:
|
||||
QStackedWidget *m_resultsStack = nullptr;
|
||||
AlbumListView *m_albumList = nullptr;
|
||||
QTreeWidget *m_playlistList = nullptr;
|
||||
QPushButton *m_loadMorePlaylistsBtn = nullptr;
|
||||
bool m_searchViewportFilled = false;
|
||||
BrowseMode m_mode = BrowseMode::Genres;
|
||||
bool m_genresLoaded = false;
|
||||
int m_lastGenreComboIndex = 0;
|
||||
|
||||
@@ -61,14 +61,14 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
|
||||
m_artistView = new ArtistView(backend, queue, this);
|
||||
m_genreBrowser = new GenreBrowserView(backend, queue, this);
|
||||
|
||||
m_stack->addWidget(m_welcome); // 0
|
||||
m_stack->addWidget(tracksPage); // 1
|
||||
m_stack->addWidget(m_albumList); // 2
|
||||
m_stack->addWidget(m_artistList); // 3
|
||||
m_stack->addWidget(m_artistView); // 4
|
||||
m_stack->addWidget(m_genreBrowser); // 5
|
||||
m_stack->addWidget(m_welcome); // PageWelcome
|
||||
m_stack->addWidget(tracksPage); // PageTracks
|
||||
m_stack->addWidget(m_albumList); // PageAlbumList
|
||||
m_stack->addWidget(m_artistList); // PageArtistList
|
||||
m_stack->addWidget(m_artistView); // PageArtistDetail
|
||||
m_stack->addWidget(m_genreBrowser); // PageGenreBrowser
|
||||
|
||||
m_stack->setCurrentIndex(0);
|
||||
m_stack->setCurrentIndex(PageWelcome);
|
||||
|
||||
connect(m_albumList, &AlbumListView::albumSelected, this, &MainContent::albumRequested);
|
||||
connect(m_artistList, &ArtistListView::artistSelected, this, &MainContent::artistRequested);
|
||||
@@ -80,7 +80,7 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
|
||||
connect(m_genreBrowser, &GenreBrowserView::playTrackRequested, this, &MainContent::playTrackRequested);
|
||||
}
|
||||
|
||||
void MainContent::showWelcome() { m_stack->setCurrentIndex(0); }
|
||||
void MainContent::showWelcome() { m_stack->setCurrentIndex(PageWelcome); }
|
||||
|
||||
void MainContent::showAlbum(const QJsonObject &album)
|
||||
{
|
||||
@@ -89,46 +89,46 @@ void MainContent::showAlbum(const QJsonObject &album)
|
||||
albumId = QString::number(static_cast<qint64>(album["id"].toDouble()));
|
||||
m_header->setAlbum(album, m_favAlbumIds.contains(albumId));
|
||||
m_tracks->loadAlbum(album);
|
||||
m_stack->setCurrentIndex(1);
|
||||
m_stack->setCurrentIndex(PageTracks);
|
||||
}
|
||||
|
||||
void MainContent::showPlaylist(const QJsonObject &playlist, bool isFollowed, bool isOwned)
|
||||
{
|
||||
m_header->setPlaylist(playlist, isFollowed, isOwned);
|
||||
m_tracks->loadPlaylist(playlist);
|
||||
m_stack->setCurrentIndex(1);
|
||||
m_stack->setCurrentIndex(PageTracks);
|
||||
}
|
||||
|
||||
void MainContent::showFavTracks(const QJsonObject &result)
|
||||
{
|
||||
m_header->hide();
|
||||
m_tracks->loadTracks(result["items"].toArray());
|
||||
m_stack->setCurrentIndex(1);
|
||||
m_stack->setCurrentIndex(PageTracks);
|
||||
}
|
||||
|
||||
void MainContent::showSearchTracks(const QJsonArray &tracks)
|
||||
{
|
||||
m_header->hide();
|
||||
m_tracks->loadSearchTracks(tracks);
|
||||
m_stack->setCurrentIndex(1);
|
||||
m_stack->setCurrentIndex(PageTracks);
|
||||
}
|
||||
|
||||
void MainContent::showFavAlbums(const QJsonObject &result)
|
||||
{
|
||||
m_albumList->setAlbums(result["items"].toArray());
|
||||
m_stack->setCurrentIndex(2);
|
||||
m_stack->setCurrentIndex(PageAlbumList);
|
||||
}
|
||||
|
||||
void MainContent::showFavArtists(const QJsonObject &result)
|
||||
{
|
||||
m_artistList->setArtists(result["items"].toArray());
|
||||
m_stack->setCurrentIndex(3);
|
||||
m_stack->setCurrentIndex(PageArtistList);
|
||||
}
|
||||
|
||||
void MainContent::showArtist(const QJsonObject &artist)
|
||||
{
|
||||
m_artistView->setArtist(artist);
|
||||
m_stack->setCurrentIndex(4);
|
||||
m_stack->setCurrentIndex(PageArtistDetail);
|
||||
}
|
||||
|
||||
void MainContent::updateArtistReleases(const QString &releaseType, const QJsonArray &items, bool hasMore, int offset)
|
||||
@@ -160,14 +160,14 @@ void MainContent::showGenreBrowser()
|
||||
{
|
||||
m_genreBrowser->ensureGenresLoaded();
|
||||
m_genreBrowser->setBrowseMode(GenreBrowserView::BrowseMode::Genres);
|
||||
m_stack->setCurrentIndex(5);
|
||||
m_stack->setCurrentIndex(PageGenreBrowser);
|
||||
}
|
||||
|
||||
void MainContent::showPlaylistBrowser()
|
||||
{
|
||||
m_genreBrowser->ensureGenresLoaded();
|
||||
m_genreBrowser->setBrowseMode(GenreBrowserView::BrowseMode::PlaylistSearch);
|
||||
m_stack->setCurrentIndex(5);
|
||||
m_stack->setCurrentIndex(PageGenreBrowser);
|
||||
}
|
||||
|
||||
void MainContent::setCurrentPlaylistFollowed(bool followed)
|
||||
|
||||
@@ -52,6 +52,15 @@ signals:
|
||||
void playTrackRequested(qint64 trackId);
|
||||
|
||||
private:
|
||||
enum StackPage {
|
||||
PageWelcome = 0,
|
||||
PageTracks = 1,
|
||||
PageAlbumList = 2,
|
||||
PageArtistList = 3,
|
||||
PageArtistDetail = 4,
|
||||
PageGenreBrowser = 5,
|
||||
};
|
||||
|
||||
QobuzBackend *m_backend = nullptr;
|
||||
QStackedWidget *m_stack = nullptr;
|
||||
QLabel *m_welcome = nullptr;
|
||||
|
||||
@@ -41,16 +41,63 @@ MainToolBar::MainToolBar(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
|
||||
connect(m_trackLabel, &QLabel::customContextMenuRequested,
|
||||
this, [this](const QPoint &pos) {
|
||||
if (m_currentTrack.isEmpty()) return;
|
||||
QMenu menu(this);
|
||||
|
||||
const qint64 trackId = static_cast<qint64>(m_currentTrack["id"].toDouble());
|
||||
const QString albumId = m_currentTrack["album"].toObject()["id"].toString();
|
||||
const QString albumTitle = m_currentTrack["album"].toObject()["title"].toString();
|
||||
const qint64 artistId = static_cast<qint64>(
|
||||
m_currentTrack["performer"].toObject()["id"].toDouble());
|
||||
if (!albumId.isEmpty())
|
||||
menu.addAction(tr("Go to Album"), this, [this, albumId] { emit albumRequested(albumId); });
|
||||
if (artistId > 0)
|
||||
menu.addAction(tr("Go to Artist"), this, [this, artistId] { emit artistRequested(artistId); });
|
||||
if (!menu.isEmpty())
|
||||
menu.exec(m_trackLabel->mapToGlobal(pos));
|
||||
const QString artistName = m_currentTrack["performer"].toObject()["name"].toString();
|
||||
|
||||
QMenu menu(this);
|
||||
|
||||
auto *playNext = menu.addAction(QIcon(":/res/icons/media-skip-forward.svg"), tr("Play next"));
|
||||
auto *addQueue = menu.addAction(QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to queue"));
|
||||
menu.addSeparator();
|
||||
|
||||
auto *addFav = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites"));
|
||||
connect(addFav, &QAction::triggered, this, [this, trackId] {
|
||||
emit favTrackRequested(trackId);
|
||||
});
|
||||
|
||||
if (!albumId.isEmpty() || artistId > 0)
|
||||
menu.addSeparator();
|
||||
if (!albumId.isEmpty()) {
|
||||
auto *openAlbum = menu.addAction(
|
||||
QIcon(":/res/icons/view-media-album-cover.svg"),
|
||||
tr("Open album: %1").arg(QString(albumTitle).replace(QLatin1Char('&'), QStringLiteral("&&"))));
|
||||
connect(openAlbum, &QAction::triggered, this, [this, albumId] {
|
||||
emit albumRequested(albumId);
|
||||
});
|
||||
}
|
||||
if (artistId > 0) {
|
||||
auto *openArtist = menu.addAction(
|
||||
QIcon(":/res/icons/view-media-artist.svg"),
|
||||
tr("Open artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&"))));
|
||||
connect(openArtist, &QAction::triggered, this, [this, artistId] {
|
||||
emit artistRequested(artistId);
|
||||
});
|
||||
}
|
||||
|
||||
if (!m_userPlaylists.isEmpty()) {
|
||||
menu.addSeparator();
|
||||
auto *plMenu = menu.addMenu(QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to playlist"));
|
||||
for (const auto &pl : m_userPlaylists) {
|
||||
auto *act = plMenu->addAction(QString(pl.second).replace(QLatin1Char('&'), QStringLiteral("&&")));
|
||||
connect(act, &QAction::triggered, this, [this, trackId, plId = pl.first] {
|
||||
emit addToPlaylistRequested(trackId, plId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
connect(playNext, &QAction::triggered, this, [this] {
|
||||
m_queue->playNext(m_currentTrack);
|
||||
});
|
||||
connect(addQueue, &QAction::triggered, this, [this] {
|
||||
m_queue->addToQueue(m_currentTrack);
|
||||
});
|
||||
|
||||
menu.exec(m_trackLabel->mapToGlobal(pos));
|
||||
});
|
||||
|
||||
addSeparator();
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QJsonObject>
|
||||
#include <QPair>
|
||||
#include <QVector>
|
||||
|
||||
class MainToolBar : public QToolBar
|
||||
@@ -27,11 +28,15 @@ public:
|
||||
void setQueueToggleChecked(bool checked);
|
||||
void setSearchToggleChecked(bool checked);
|
||||
|
||||
void setUserPlaylists(const QVector<QPair<qint64, QString>> &playlists) { m_userPlaylists = playlists; }
|
||||
|
||||
signals:
|
||||
void searchToggled(bool visible);
|
||||
void queueToggled(bool visible);
|
||||
void albumRequested(const QString &albumId);
|
||||
void artistRequested(qint64 artistId);
|
||||
void addToPlaylistRequested(qint64 trackId, qint64 playlistId);
|
||||
void favTrackRequested(qint64 trackId);
|
||||
|
||||
protected:
|
||||
void resizeEvent(QResizeEvent *event) override;
|
||||
@@ -93,5 +98,7 @@ private:
|
||||
qint64 m_pendingSeekStartedMs = 0;
|
||||
bool m_fetchingAutoplay = false;
|
||||
|
||||
QVector<QPair<qint64, QString>> m_userPlaylists;
|
||||
|
||||
void requestAutoplaySuggestions();
|
||||
};
|
||||
|
||||
@@ -225,8 +225,8 @@ void QueuePanel::onContextMenu(const QPoint &pos)
|
||||
const int idx = item->data(UpcomingIndexRole).toInt();
|
||||
|
||||
QMenu menu(this);
|
||||
auto *removeAct = menu.addAction(tr("Remove from queue"));
|
||||
auto *toTopAct = menu.addAction(tr("Move to top (play next)"));
|
||||
auto *removeAct = menu.addAction(QIcon(":/res/icons/list-remove.svg"), tr("Remove from queue"));
|
||||
auto *toTopAct = menu.addAction(QIcon(":/res/icons/go-up.svg"), tr("Move to top (play next)"));
|
||||
|
||||
connect(removeAct, &QAction::triggered, this, [this, idx] { m_queue->removeUpcoming(idx); });
|
||||
connect(toTopAct, &QAction::triggered, this, [this, idx] { m_queue->moveUpcomingToTop(idx); });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "view.hpp"
|
||||
#include "../../util/colors.hpp"
|
||||
#include "../../util/trackinfo.hpp"
|
||||
|
||||
#include <QVBoxLayout>
|
||||
@@ -125,7 +126,7 @@ void SearchTab::onMostPopularResult(const QJsonObject &result)
|
||||
const QString artist = content["performer"].toObject()["name"].toString();
|
||||
const QString album = content["album"].toObject()["title"].toString();
|
||||
item->setText(0, QStringLiteral("T"));
|
||||
item->setForeground(0, QColor(QStringLiteral("#2FA84F")));
|
||||
item->setForeground(0, Colors::BadgeGreen);
|
||||
item->setFont(0, badgeFont);
|
||||
item->setTextAlignment(0, Qt::AlignCenter);
|
||||
item->setText(1, title);
|
||||
@@ -139,8 +140,8 @@ void SearchTab::onMostPopularResult(const QJsonObject &result)
|
||||
|| content["rights"].toObject()["hires_streamable"].toBool();
|
||||
item->setText(0, hiRes ? QStringLiteral("H") : QStringLiteral("A"));
|
||||
item->setForeground(0, hiRes
|
||||
? QColor(QStringLiteral("#FFB232"))
|
||||
: QColor(QStringLiteral("#8E8E93")));
|
||||
? Colors::QobuzOrange
|
||||
: Colors::BadgeGray);
|
||||
item->setFont(0, badgeFont);
|
||||
item->setTextAlignment(0, Qt::AlignCenter);
|
||||
item->setText(1, title);
|
||||
@@ -149,7 +150,7 @@ void SearchTab::onMostPopularResult(const QJsonObject &result)
|
||||
item->setData(1, IdRole, content["id"].toString());
|
||||
} else if (type == QStringLiteral("artists")) {
|
||||
item->setText(0, QStringLiteral("A"));
|
||||
item->setForeground(0, QColor(QStringLiteral("#2B7CD3")));
|
||||
item->setForeground(0, Colors::BadgeBlue);
|
||||
item->setFont(0, badgeFont);
|
||||
item->setTextAlignment(0, Qt::AlignCenter);
|
||||
item->setText(1, content["name"].toString());
|
||||
@@ -193,7 +194,7 @@ void SearchTab::onSearchResult(const QJsonObject &result)
|
||||
QStringList{QString(), a["title"].toString(), artist});
|
||||
if (hiRes) {
|
||||
item->setText(0, QStringLiteral("H"));
|
||||
item->setForeground(0, QColor(QStringLiteral("#FFB232")));
|
||||
item->setForeground(0, Colors::QobuzOrange);
|
||||
item->setFont(0, hiResFont);
|
||||
item->setTextAlignment(0, Qt::AlignCenter);
|
||||
}
|
||||
@@ -240,12 +241,15 @@ void SearchTab::onTrackContextMenu(const QPoint &pos)
|
||||
|
||||
QMenu menu(this);
|
||||
|
||||
auto *playNow = menu.addAction(tr("Play now"));
|
||||
auto *playNext = menu.addAction(tr("Play next"));
|
||||
auto *addQueue = menu.addAction(tr("Add to queue"));
|
||||
auto *playNow = menu.addAction(QIcon(":/res/icons/media-playback-start.svg"), tr("Play now"));
|
||||
auto *playNext = menu.addAction(QIcon(":/res/icons/media-skip-forward.svg"), tr("Play next"));
|
||||
auto *addQueue = menu.addAction(QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to queue"));
|
||||
menu.addSeparator();
|
||||
|
||||
auto *addFav = menu.addAction(tr("Add to favorites"));
|
||||
auto *favAction = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites"));
|
||||
connect(favAction, &QAction::triggered, this, [this, trackId] {
|
||||
m_backend->addFavTrack(trackId);
|
||||
});
|
||||
|
||||
// Open album / artist
|
||||
const QString albumId = trackJson["album"].toObject()["id"].toString();
|
||||
@@ -254,15 +258,20 @@ void SearchTab::onTrackContextMenu(const QPoint &pos)
|
||||
const QString artistName = trackJson["performer"].toObject()["name"].toString();
|
||||
const QString albumTitle = trackJson["album"].toObject()["title"].toString();
|
||||
|
||||
menu.addSeparator();
|
||||
if (!albumId.isEmpty() || artistId > 0)
|
||||
menu.addSeparator();
|
||||
if (!albumId.isEmpty()) {
|
||||
auto *openAlbum = menu.addAction(tr("Go to album: %1").arg(QString(albumTitle).replace(QLatin1Char('&'), QStringLiteral("&&"))));
|
||||
auto *openAlbum = menu.addAction(
|
||||
QIcon(":/res/icons/view-media-album-cover.svg"),
|
||||
tr("Open album: %1").arg(QString(albumTitle).replace(QLatin1Char('&'), QStringLiteral("&&"))));
|
||||
connect(openAlbum, &QAction::triggered, this, [this, albumId] {
|
||||
emit albumSelected(albumId);
|
||||
});
|
||||
}
|
||||
if (artistId > 0) {
|
||||
auto *openArtist = menu.addAction(tr("Go to artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&"))));
|
||||
auto *openArtist = menu.addAction(
|
||||
QIcon(":/res/icons/view-media-artist.svg"),
|
||||
tr("Open artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&"))));
|
||||
connect(openArtist, &QAction::triggered, this, [this, artistId] {
|
||||
emit artistSelected(artistId);
|
||||
});
|
||||
@@ -271,9 +280,9 @@ void SearchTab::onTrackContextMenu(const QPoint &pos)
|
||||
// Add to playlist submenu
|
||||
if (!m_userPlaylists.isEmpty()) {
|
||||
menu.addSeparator();
|
||||
auto *plMenu = menu.addMenu(tr("Add to playlist"));
|
||||
auto *plMenu = menu.addMenu(QIcon(":/res/icons/media-playlist-append.svg"), tr("Add to playlist"));
|
||||
for (const auto &pl : m_userPlaylists) {
|
||||
auto *act = plMenu->addAction(pl.second);
|
||||
auto *act = plMenu->addAction(QString(pl.second).replace(QLatin1Char('&'), QStringLiteral("&&")));
|
||||
connect(act, &QAction::triggered, this, [this, trackId, plId = pl.first] {
|
||||
emit addToPlaylistRequested(trackId, plId);
|
||||
});
|
||||
@@ -293,9 +302,6 @@ void SearchTab::onTrackContextMenu(const QPoint &pos)
|
||||
connect(addQueue, &QAction::triggered, this, [this, trackJson] {
|
||||
m_queue->addToQueue(trackJson);
|
||||
});
|
||||
connect(addFav, &QAction::triggered, this, [this, trackId] {
|
||||
m_backend->addFavTrack(trackId);
|
||||
});
|
||||
connect(info, &QAction::triggered, this, [this, trackJson] {
|
||||
showTrackInfo(trackJson);
|
||||
});
|
||||
@@ -314,15 +320,17 @@ void SearchTab::onAlbumContextMenu(const QPoint &pos)
|
||||
|
||||
QMenu menu(this);
|
||||
|
||||
auto *openAlbum = menu.addAction(tr("Open album"));
|
||||
auto *addFav = menu.addAction(tr("Add to favorites"));
|
||||
auto *openAlbum = menu.addAction(QIcon(":/res/icons/view-media-album-cover.svg"), tr("Open album"));
|
||||
auto *addFav = menu.addAction(QIcon(":/res/icons/starred-symbolic.svg"), tr("Add to favorites"));
|
||||
|
||||
const qint64 artistId = static_cast<qint64>(
|
||||
albumJson["artist"].toObject()["id"].toDouble());
|
||||
const QString artistName = albumJson["artist"].toObject()["name"].toString();
|
||||
if (artistId > 0) {
|
||||
menu.addSeparator();
|
||||
auto *openArtist = menu.addAction(tr("Go to artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&"))));
|
||||
auto *openArtist = menu.addAction(
|
||||
QIcon(":/res/icons/view-media-artist.svg"),
|
||||
tr("Open artist: %1").arg(QString(artistName).replace(QLatin1Char('&'), QStringLiteral("&&"))));
|
||||
connect(openArtist, &QAction::triggered, this, [this, artistId] {
|
||||
emit artistSelected(artistId);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "../util/colors.hpp"
|
||||
|
||||
#include <QWidget>
|
||||
#include <QHBoxLayout>
|
||||
#include <QVBoxLayout>
|
||||
@@ -63,7 +65,7 @@ public:
|
||||
|
||||
m_meta = new QLabel(info);
|
||||
QPalette mp = m_meta->palette();
|
||||
mp.setColor(QPalette::WindowText, QColor(0xaa, 0xaa, 0xaa));
|
||||
mp.setColor(QPalette::WindowText, Colors::SubduedText);
|
||||
m_meta->setPalette(mp);
|
||||
vlay->addWidget(m_meta);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user