Compare commits
7 Commits
fix/queue-
...
feat/seek-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
373fc2b43c | ||
|
|
74e43b9713 | ||
|
|
7e2a04ecff | ||
|
|
4a9a7f9cbb | ||
|
|
b9b47f80e7 | ||
|
|
ecaee4d907 | ||
|
|
d5dedacc36 |
@@ -62,6 +62,7 @@ void qobuz_backend_pause(QobuzBackendOpaque *backend);
|
||||
void qobuz_backend_resume(QobuzBackendOpaque *backend);
|
||||
void qobuz_backend_stop(QobuzBackendOpaque *backend);
|
||||
void qobuz_backend_set_volume(QobuzBackendOpaque *backend, uint8_t volume);
|
||||
void qobuz_backend_seek(QobuzBackendOpaque *backend, uint64_t position_secs);
|
||||
uint64_t qobuz_backend_get_position(const QobuzBackendOpaque *backend);
|
||||
uint64_t qobuz_backend_get_duration(const QobuzBackendOpaque *backend);
|
||||
uint8_t qobuz_backend_get_volume(const QobuzBackendOpaque *backend);
|
||||
|
||||
@@ -248,7 +248,7 @@ impl QobuzClient {
|
||||
.get_request("artist/get")
|
||||
.query(&[
|
||||
("artist_id", artist_id.to_string()),
|
||||
("extra", "albums,biography".to_string()),
|
||||
("extra", "albums".to_string()),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
@@ -117,6 +117,7 @@ pub struct ArtistDto {
|
||||
pub albums_count: Option<i32>,
|
||||
pub image: Option<ImageDto>,
|
||||
pub biography: Option<BiographyDto>,
|
||||
pub albums: Option<SearchResultItems<AlbumDto>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Serialize)]
|
||||
|
||||
@@ -402,6 +402,11 @@ pub unsafe extern "C" fn qobuz_backend_set_volume(ptr: *mut Backend, volume: u8)
|
||||
(*ptr).0.player.set_volume(volume);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qobuz_backend_seek(ptr: *mut Backend, position_secs: u64) {
|
||||
(*ptr).0.player.seek(position_secs);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn qobuz_backend_get_position(ptr: *const Backend) -> u64 {
|
||||
(*ptr).0.player.status.get_position()
|
||||
|
||||
@@ -8,10 +8,11 @@ use std::sync::{
|
||||
use symphonia::core::{
|
||||
codecs::DecoderOptions,
|
||||
errors::Error as SymphoniaError,
|
||||
formats::FormatOptions,
|
||||
formats::{FormatOptions, SeekMode, SeekTo},
|
||||
io::{MediaSource, MediaSourceStream},
|
||||
meta::MetadataOptions,
|
||||
probe::Hint,
|
||||
units::Time,
|
||||
};
|
||||
|
||||
use crate::player::{output::AudioOutput, PlayerStatus};
|
||||
@@ -172,6 +173,21 @@ pub fn play_track(
|
||||
}
|
||||
}
|
||||
|
||||
if status.seek_requested.load(Ordering::SeqCst) {
|
||||
status.seek_requested.store(false, Ordering::SeqCst);
|
||||
let target = status.seek_target_secs.load(Ordering::Relaxed);
|
||||
let seeked = format.seek(
|
||||
SeekMode::Coarse,
|
||||
SeekTo::Time { time: Time::from(target), track_id: None },
|
||||
);
|
||||
if let Ok(s) = seeked {
|
||||
let actual = s.actual_ts / sample_rate as u64;
|
||||
status.position_secs.store(actual, Ordering::Relaxed);
|
||||
}
|
||||
decoder.reset();
|
||||
continue;
|
||||
}
|
||||
|
||||
let packet = match format.next_packet() {
|
||||
Ok(p) => p,
|
||||
Err(SymphoniaError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
|
||||
@@ -44,6 +44,9 @@ pub struct PlayerStatus {
|
||||
pub current_track: Arc<std::sync::Mutex<Option<TrackDto>>>,
|
||||
/// Set to true by the decode thread when a track finishes naturally.
|
||||
pub track_finished: Arc<AtomicBool>,
|
||||
/// Set by the player loop when a seek command arrives; cleared by the decode thread.
|
||||
pub seek_requested: Arc<AtomicBool>,
|
||||
pub seek_target_secs: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl PlayerStatus {
|
||||
@@ -55,6 +58,8 @@ impl PlayerStatus {
|
||||
volume: Arc::new(AtomicU8::new(80)),
|
||||
current_track: Arc::new(std::sync::Mutex::new(None)),
|
||||
track_finished: Arc::new(AtomicBool::new(false)),
|
||||
seek_requested: Arc::new(AtomicBool::new(false)),
|
||||
seek_target_secs: Arc::new(AtomicU64::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +126,11 @@ impl Player {
|
||||
self.status.volume.store(vol, Ordering::Relaxed);
|
||||
self.send(PlayerCommand::SetVolume(vol));
|
||||
}
|
||||
|
||||
pub fn seek(&self, secs: u64) {
|
||||
self.status.seek_target_secs.store(secs, Ordering::Relaxed);
|
||||
self.status.seek_requested.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn player_loop(rx: std::sync::mpsc::Receiver<PlayerCommand>, status: PlayerStatus) {
|
||||
@@ -177,7 +187,11 @@ fn player_loop(rx: std::sync::mpsc::Receiver<PlayerCommand>, status: PlayerStatu
|
||||
status.position_secs.store(0, Ordering::Relaxed);
|
||||
status.duration_secs.store(0, Ordering::Relaxed);
|
||||
}
|
||||
PlayerCommand::SetVolume(_) | PlayerCommand::Seek(_) => {}
|
||||
PlayerCommand::SetVolume(_) => {}
|
||||
PlayerCommand::Seek(secs) => {
|
||||
status.seek_target_secs.store(secs, Ordering::Relaxed);
|
||||
status.seek_requested.store(true, Ordering::SeqCst);
|
||||
}
|
||||
},
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
|
||||
@@ -22,6 +22,9 @@ target_sources(qobuz-qt PRIVATE
|
||||
view/context/view.cpp
|
||||
view/queuepanel.hpp
|
||||
view/queuepanel.cpp
|
||||
view/albumlistview.hpp
|
||||
view/artistlistview.hpp
|
||||
view/artistview.hpp
|
||||
view/sidepanel/view.hpp
|
||||
view/sidepanel/view.cpp
|
||||
|
||||
@@ -41,6 +44,9 @@ target_sources(qobuz-qt PRIVATE
|
||||
dialog/settings.hpp
|
||||
dialog/settings.cpp
|
||||
|
||||
# Scrobbler (header-only)
|
||||
scrobbler/lastfm.hpp
|
||||
|
||||
# Widgets (header-only)
|
||||
widget/clickableslider.hpp
|
||||
widget/volumebutton.hpp
|
||||
|
||||
@@ -128,6 +128,11 @@ void QobuzBackend::setVolume(int volume)
|
||||
qobuz_backend_set_volume(m_backend, static_cast<quint8>(qBound(0, volume, 100)));
|
||||
}
|
||||
|
||||
void QobuzBackend::seek(quint64 positionSecs)
|
||||
{
|
||||
qobuz_backend_seek(m_backend, positionSecs);
|
||||
}
|
||||
|
||||
quint64 QobuzBackend::position() const { return qobuz_backend_get_position(m_backend); }
|
||||
quint64 QobuzBackend::duration() const { return qobuz_backend_get_duration(m_backend); }
|
||||
int QobuzBackend::volume() const { return qobuz_backend_get_volume(m_backend); }
|
||||
|
||||
@@ -48,6 +48,7 @@ public:
|
||||
void resume();
|
||||
void stop();
|
||||
void setVolume(int volume);
|
||||
void seek(quint64 positionSecs);
|
||||
|
||||
quint64 position() const;
|
||||
quint64 duration() const;
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
#include "settings.hpp"
|
||||
#include "../util/settings.hpp"
|
||||
#include "../scrobbler/lastfm.hpp"
|
||||
|
||||
#include <QVBoxLayout>
|
||||
#include <QFormLayout>
|
||||
#include <QDialogButtonBox>
|
||||
#include <QGroupBox>
|
||||
#include <QHBoxLayout>
|
||||
#include <QNetworkReply>
|
||||
|
||||
SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent)
|
||||
{
|
||||
setWindowTitle(tr("Settings"));
|
||||
setMinimumWidth(400);
|
||||
setMinimumWidth(420);
|
||||
|
||||
m_nam = new QNetworkAccessManager(this);
|
||||
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
|
||||
@@ -30,25 +35,98 @@ SettingsDialog::SettingsDialog(QWidget *parent) : QDialog(parent)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
playLayout->addRow(tr("Preferred quality:"), m_formatBox);
|
||||
layout->addWidget(playGroup);
|
||||
|
||||
// --- Last.fm group ---
|
||||
auto *lfmGroup = new QGroupBox(tr("Last.fm Scrobbling"), this);
|
||||
auto *lfmLayout = new QFormLayout(lfmGroup);
|
||||
|
||||
m_lastFmEnabled = new QCheckBox(tr("Enable scrobbling"), lfmGroup);
|
||||
m_lastFmEnabled->setChecked(AppSettings::instance().lastFmEnabled());
|
||||
lfmLayout->addRow(m_lastFmEnabled);
|
||||
|
||||
m_lastFmApiKey = new QLineEdit(AppSettings::instance().lastFmApiKey(), lfmGroup);
|
||||
m_lastFmApiKey->setPlaceholderText(tr("Get one at last.fm/api"));
|
||||
lfmLayout->addRow(tr("API Key:"), m_lastFmApiKey);
|
||||
|
||||
m_lastFmApiSecret = new QLineEdit(AppSettings::instance().lastFmApiSecret(), lfmGroup);
|
||||
m_lastFmApiSecret->setEchoMode(QLineEdit::Password);
|
||||
lfmLayout->addRow(tr("API Secret:"), m_lastFmApiSecret);
|
||||
|
||||
m_lastFmUsername = new QLineEdit(lfmGroup);
|
||||
m_lastFmUsername->setPlaceholderText(tr("Last.fm username"));
|
||||
lfmLayout->addRow(tr("Username:"), m_lastFmUsername);
|
||||
|
||||
m_lastFmPassword = new QLineEdit(lfmGroup);
|
||||
m_lastFmPassword->setEchoMode(QLineEdit::Password);
|
||||
m_lastFmPassword->setPlaceholderText(tr("Last.fm password"));
|
||||
lfmLayout->addRow(tr("Password:"), m_lastFmPassword);
|
||||
|
||||
auto *connectRow = new QHBoxLayout;
|
||||
m_lastFmConnect = new QPushButton(tr("Connect"), lfmGroup);
|
||||
m_lastFmStatus = new QLabel(lfmGroup);
|
||||
|
||||
const bool hasSession = !AppSettings::instance().lastFmSessionKey().isEmpty();
|
||||
m_lastFmStatus->setText(hasSession ? tr("Connected ✓") : tr("Not connected"));
|
||||
m_lastFmStatus->setStyleSheet(hasSession ? "color: green;" : "color: gray;");
|
||||
|
||||
connectRow->addWidget(m_lastFmConnect);
|
||||
connectRow->addWidget(m_lastFmStatus, 1);
|
||||
lfmLayout->addRow(connectRow);
|
||||
|
||||
layout->addWidget(lfmGroup);
|
||||
layout->addStretch();
|
||||
|
||||
auto *buttons = new QDialogButtonBox(
|
||||
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
|
||||
layout->addWidget(buttons);
|
||||
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, [this] {
|
||||
applyChanges();
|
||||
accept();
|
||||
});
|
||||
connect(m_lastFmConnect, &QPushButton::clicked, this, &SettingsDialog::onLastFmConnect);
|
||||
connect(buttons, &QDialogButtonBox::accepted, this, [this] { applyChanges(); accept(); });
|
||||
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
||||
}
|
||||
|
||||
void SettingsDialog::applyChanges()
|
||||
{
|
||||
AppSettings::instance().setPreferredFormat(
|
||||
m_formatBox->currentData().toInt());
|
||||
AppSettings::instance().setPreferredFormat(m_formatBox->currentData().toInt());
|
||||
AppSettings::instance().setLastFmEnabled(m_lastFmEnabled->isChecked());
|
||||
AppSettings::instance().setLastFmApiKey(m_lastFmApiKey->text().trimmed());
|
||||
AppSettings::instance().setLastFmApiSecret(m_lastFmApiSecret->text().trimmed());
|
||||
}
|
||||
|
||||
void SettingsDialog::onLastFmConnect()
|
||||
{
|
||||
const QString apiKey = m_lastFmApiKey->text().trimmed();
|
||||
const QString apiSecret = m_lastFmApiSecret->text().trimmed();
|
||||
const QString username = m_lastFmUsername->text().trimmed();
|
||||
const QString password = m_lastFmPassword->text();
|
||||
|
||||
if (apiKey.isEmpty() || apiSecret.isEmpty() || username.isEmpty() || password.isEmpty()) {
|
||||
m_lastFmStatus->setText(tr("Fill in all fields first."));
|
||||
m_lastFmStatus->setStyleSheet("color: red;");
|
||||
return;
|
||||
}
|
||||
|
||||
// Temporarily apply so the scrobbler util can use them for signing
|
||||
AppSettings::instance().setLastFmApiKey(apiKey);
|
||||
AppSettings::instance().setLastFmApiSecret(apiSecret);
|
||||
|
||||
m_lastFmConnect->setEnabled(false);
|
||||
m_lastFmStatus->setText(tr("Connecting…"));
|
||||
m_lastFmStatus->setStyleSheet("color: gray;");
|
||||
|
||||
// Reuse LastFmScrobbler::authenticate as a utility (temp instance)
|
||||
auto *tmp = new LastFmScrobbler(this);
|
||||
tmp->authenticate(username, password, [this, tmp](bool ok, const QString &err) {
|
||||
tmp->deleteLater();
|
||||
m_lastFmConnect->setEnabled(true);
|
||||
if (ok) {
|
||||
m_lastFmStatus->setText(tr("Connected ✓"));
|
||||
m_lastFmStatus->setStyleSheet("color: green;");
|
||||
} else {
|
||||
m_lastFmStatus->setText(tr("Error: %1").arg(err));
|
||||
m_lastFmStatus->setStyleSheet("color: red;");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
#include <QDialog>
|
||||
#include <QComboBox>
|
||||
#include <QLabel>
|
||||
#include <QLineEdit>
|
||||
#include <QCheckBox>
|
||||
#include <QPushButton>
|
||||
#include <QNetworkAccessManager>
|
||||
|
||||
class SettingsDialog : public QDialog
|
||||
{
|
||||
@@ -12,7 +16,19 @@ public:
|
||||
explicit SettingsDialog(QWidget *parent = nullptr);
|
||||
|
||||
private:
|
||||
// Playback
|
||||
QComboBox *m_formatBox = nullptr;
|
||||
|
||||
// Last.fm
|
||||
QCheckBox *m_lastFmEnabled = nullptr;
|
||||
QLineEdit *m_lastFmApiKey = nullptr;
|
||||
QLineEdit *m_lastFmApiSecret = nullptr;
|
||||
QLineEdit *m_lastFmUsername = nullptr;
|
||||
QLineEdit *m_lastFmPassword = nullptr;
|
||||
QPushButton *m_lastFmConnect = nullptr;
|
||||
QLabel *m_lastFmStatus = nullptr;
|
||||
QNetworkAccessManager *m_nam = nullptr;
|
||||
|
||||
void applyChanges();
|
||||
void onLastFmConnect();
|
||||
};
|
||||
|
||||
@@ -35,10 +35,6 @@ Tracks::Tracks(QobuzBackend *backend, PlayQueue *queue, QWidget *parent)
|
||||
connect(this, &QTreeView::customContextMenuRequested,
|
||||
this, &Tracks::onContextMenu);
|
||||
|
||||
// Re-sync queue order whenever the model is re-sorted (user clicked header
|
||||
// or setTracks re-applied an existing sort).
|
||||
connect(m_model, &TrackListModel::sortApplied,
|
||||
this, &Tracks::syncQueueToModel);
|
||||
}
|
||||
|
||||
void Tracks::loadTracks(const QJsonArray &tracks)
|
||||
@@ -65,14 +61,9 @@ void Tracks::loadSearchTracks(const QJsonArray &tracks)
|
||||
|
||||
void Tracks::setPlayingTrackId(qint64 id)
|
||||
{
|
||||
m_playingId = id;
|
||||
m_model->setPlayingId(id);
|
||||
}
|
||||
|
||||
void Tracks::syncQueueToModel()
|
||||
{
|
||||
m_queue->reorderContext(m_model->currentTracksJson(), m_playingId);
|
||||
}
|
||||
|
||||
void Tracks::onDoubleClicked(const QModelIndex &index)
|
||||
{
|
||||
|
||||
@@ -29,13 +29,11 @@ namespace List
|
||||
void playTrackRequested(qint64 trackId);
|
||||
|
||||
private:
|
||||
TrackListModel *m_model = nullptr;
|
||||
QobuzBackend *m_backend = nullptr;
|
||||
PlayQueue *m_queue = nullptr;
|
||||
qint64 m_playingId = 0;
|
||||
TrackListModel *m_model = nullptr;
|
||||
QobuzBackend *m_backend = nullptr;
|
||||
PlayQueue *m_queue = nullptr;
|
||||
|
||||
void onDoubleClicked(const QModelIndex &index);
|
||||
void onContextMenu(const QPoint &pos);
|
||||
void syncQueueToModel();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ int main(int argc, char *argv[])
|
||||
darkPalette.setColor(QPalette::Link, QColor(42, 130, 218));
|
||||
darkPalette.setColor(QPalette::Highlight, QColor(42, 130, 218));
|
||||
darkPalette.setColor(QPalette::HighlightedText, Qt::black);
|
||||
darkPalette.setColor(QPalette::PlaceholderText, QColor(140, 140, 140));
|
||||
darkPalette.setColor(QPalette::Disabled, QPalette::Text, QColor(127, 127, 127));
|
||||
darkPalette.setColor(QPalette::Disabled, QPalette::ButtonText, QColor(127, 127, 127));
|
||||
app.setPalette(darkPalette);
|
||||
|
||||
@@ -60,13 +60,23 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
|
||||
setupMenuBar();
|
||||
statusBar()->showMessage(tr("Ready"));
|
||||
|
||||
// ---- 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);
|
||||
|
||||
// ---- Backend signals ----
|
||||
connect(m_backend, &QobuzBackend::loginSuccess, this, &MainWindow::onLoginSuccess);
|
||||
connect(m_backend, &QobuzBackend::loginError, this, &MainWindow::onLoginError);
|
||||
connect(m_backend, &QobuzBackend::favTracksLoaded, this, &MainWindow::onFavTracksLoaded);
|
||||
connect(m_backend, &QobuzBackend::favAlbumsLoaded, this, &MainWindow::onFavAlbumsLoaded);
|
||||
connect(m_backend, &QobuzBackend::albumLoaded, this, &MainWindow::onAlbumLoaded);
|
||||
connect(m_backend, &QobuzBackend::artistLoaded, this, &MainWindow::onArtistLoaded);
|
||||
connect(m_backend, &QobuzBackend::favTracksLoaded, this, &MainWindow::onFavTracksLoaded);
|
||||
connect(m_backend, &QobuzBackend::favAlbumsLoaded, this, &MainWindow::onFavAlbumsLoaded);
|
||||
connect(m_backend, &QobuzBackend::favArtistsLoaded, this, &MainWindow::onFavArtistsLoaded);
|
||||
connect(m_backend, &QobuzBackend::albumLoaded, this, &MainWindow::onAlbumLoaded);
|
||||
connect(m_backend, &QobuzBackend::artistLoaded, this, &MainWindow::onArtistLoaded);
|
||||
connect(m_backend, &QobuzBackend::playlistLoaded, this, &MainWindow::onPlaylistLoaded);
|
||||
connect(m_backend, &QobuzBackend::trackChanged, this, &MainWindow::onTrackChanged);
|
||||
connect(m_backend, &QobuzBackend::error, this, [this](const QString &msg) {
|
||||
@@ -104,6 +114,16 @@ MainWindow::MainWindow(QobuzBackend *backend, QWidget *parent)
|
||||
connect(m_sidePanel, &SidePanel::View::trackPlayRequested,
|
||||
this, &MainWindow::onPlayTrackRequested);
|
||||
|
||||
// ---- Album / artist navigation from content views ----
|
||||
connect(m_content, &MainContent::albumRequested,
|
||||
this, &MainWindow::onSearchAlbumSelected);
|
||||
connect(m_content, &MainContent::artistRequested,
|
||||
this, &MainWindow::onSearchArtistSelected);
|
||||
|
||||
// ---- Queue panel ----
|
||||
connect(m_queuePanel, &QueuePanel::skipToTrackRequested,
|
||||
this, &MainWindow::onPlayTrackRequested);
|
||||
|
||||
// ---- Toolbar toggles ----
|
||||
connect(m_toolBar, &MainToolBar::searchToggled, this, &MainWindow::onSearchToggled);
|
||||
connect(m_toolBar, &MainToolBar::queueToggled,
|
||||
@@ -224,9 +244,16 @@ void MainWindow::onFavTracksLoaded(const QJsonObject &result)
|
||||
|
||||
void MainWindow::onFavAlbumsLoaded(const QJsonObject &result)
|
||||
{
|
||||
const int total = result["total"].toInt();
|
||||
statusBar()->showMessage(tr("%1 favorite albums").arg(total), 4000);
|
||||
// TODO: album grid view
|
||||
m_content->showFavAlbums(result);
|
||||
statusBar()->showMessage(
|
||||
tr("%1 favorite albums").arg(result["total"].toInt()), 4000);
|
||||
}
|
||||
|
||||
void MainWindow::onFavArtistsLoaded(const QJsonObject &result)
|
||||
{
|
||||
m_content->showFavArtists(result);
|
||||
statusBar()->showMessage(
|
||||
tr("%1 favorite artists").arg(result["total"].toInt()), 4000);
|
||||
}
|
||||
|
||||
void MainWindow::onAlbumLoaded(const QJsonObject &album)
|
||||
@@ -238,9 +265,9 @@ void MainWindow::onAlbumLoaded(const QJsonObject &album)
|
||||
|
||||
void MainWindow::onArtistLoaded(const QJsonObject &artist)
|
||||
{
|
||||
m_content->showArtist(artist);
|
||||
statusBar()->showMessage(
|
||||
tr("Artist: %1").arg(artist["name"].toString()), 4000);
|
||||
// TODO: artist view
|
||||
}
|
||||
|
||||
void MainWindow::onPlaylistLoaded(const QJsonObject &playlist)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include "view/queuepanel.hpp"
|
||||
#include "view/sidepanel/view.hpp"
|
||||
#include "list/library.hpp"
|
||||
#include "scrobbler/lastfm.hpp"
|
||||
|
||||
#include <QMainWindow>
|
||||
#include <QDockWidget>
|
||||
@@ -27,6 +28,7 @@ private slots:
|
||||
|
||||
void onFavTracksLoaded(const QJsonObject &result);
|
||||
void onFavAlbumsLoaded(const QJsonObject &result);
|
||||
void onFavArtistsLoaded(const QJsonObject &result);
|
||||
void onAlbumLoaded(const QJsonObject &album);
|
||||
void onArtistLoaded(const QJsonObject &artist);
|
||||
void onPlaylistLoaded(const QJsonObject &playlist);
|
||||
@@ -50,6 +52,7 @@ private:
|
||||
QueuePanel *m_queuePanel = nullptr;
|
||||
SidePanel::View *m_sidePanel = nullptr;
|
||||
QDockWidget *m_libraryDock = nullptr;
|
||||
LastFmScrobbler *m_scrobbler = nullptr;
|
||||
|
||||
void setupMenuBar();
|
||||
void tryRestoreSession();
|
||||
|
||||
@@ -184,6 +184,39 @@ public:
|
||||
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 {};
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
|
||||
/// Move an upcoming item (by its index in upcomingTracks()) to the front of playNext.
|
||||
void moveUpcomingToTop(int upcomingIndex)
|
||||
{
|
||||
|
||||
179
src/scrobbler/lastfm.hpp
Normal file
179
src/scrobbler/lastfm.hpp
Normal file
@@ -0,0 +1,179 @@
|
||||
#pragma once
|
||||
|
||||
#include "../util/settings.hpp"
|
||||
|
||||
#include <QObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QUrlQuery>
|
||||
#include <QUrl>
|
||||
#include <QCryptographicHash>
|
||||
#include <QDateTime>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QMap>
|
||||
#include <functional>
|
||||
|
||||
/// Handles Last.fm now-playing updates and scrobbling.
|
||||
/// Connect to QobuzBackend signals and call onTrackStarted / onPositionChanged / onTrackFinished.
|
||||
class LastFmScrobbler : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit LastFmScrobbler(QObject *parent = nullptr)
|
||||
: QObject(parent)
|
||||
, m_nam(new QNetworkAccessManager(this))
|
||||
{}
|
||||
|
||||
/// Authenticate via auth.getMobileSession and store the session key.
|
||||
/// callback(success, errorMessage)
|
||||
void authenticate(const QString &username, const QString &password,
|
||||
std::function<void(bool, const QString &)> callback)
|
||||
{
|
||||
const QString apiKey = AppSettings::instance().lastFmApiKey();
|
||||
const QString apiSecret = AppSettings::instance().lastFmApiSecret();
|
||||
if (apiKey.isEmpty() || apiSecret.isEmpty()) {
|
||||
callback(false, tr("API key or secret is not set."));
|
||||
return;
|
||||
}
|
||||
|
||||
QMap<QString,QString> params;
|
||||
params["method"] = QStringLiteral("auth.getMobileSession");
|
||||
params["api_key"] = apiKey;
|
||||
params["username"] = username;
|
||||
params["password"] = password;
|
||||
params["api_sig"] = buildSig(params, apiSecret);
|
||||
params["format"] = QStringLiteral("json");
|
||||
|
||||
auto *reply = m_nam->post(apiRequest(), encodeBody(params));
|
||||
connect(reply, &QNetworkReply::finished, this, [reply, callback] {
|
||||
reply->deleteLater();
|
||||
const QJsonObject obj = QJsonDocument::fromJson(reply->readAll()).object();
|
||||
const QString key = obj["session"].toObject()["key"].toString();
|
||||
if (!key.isEmpty()) {
|
||||
AppSettings::instance().setLastFmSessionKey(key);
|
||||
callback(true, {});
|
||||
} else {
|
||||
callback(false, obj["message"].toString(tr("Authentication failed.")));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bool isEnabled() const
|
||||
{
|
||||
return AppSettings::instance().lastFmEnabled()
|
||||
&& !AppSettings::instance().lastFmApiKey().isEmpty()
|
||||
&& !AppSettings::instance().lastFmApiSecret().isEmpty()
|
||||
&& !AppSettings::instance().lastFmSessionKey().isEmpty();
|
||||
}
|
||||
|
||||
public slots:
|
||||
void onTrackStarted(const QJsonObject &track)
|
||||
{
|
||||
m_title = track["title"].toString();
|
||||
m_artist = track["performer"].toObject()["name"].toString();
|
||||
if (m_artist.isEmpty())
|
||||
m_artist = track["album"].toObject()["artist"].toObject()["name"].toString();
|
||||
m_album = track["album"].toObject()["title"].toString();
|
||||
m_duration = static_cast<qint64>(track["duration"].toDouble());
|
||||
m_startTime = QDateTime::currentSecsSinceEpoch();
|
||||
m_playedSecs = 0;
|
||||
m_scrobbled = false;
|
||||
|
||||
if (!isEnabled() || m_title.isEmpty() || m_duration < 30) return;
|
||||
updateNowPlaying();
|
||||
}
|
||||
|
||||
void onPositionChanged(quint64 positionSecs, quint64 /*duration*/)
|
||||
{
|
||||
m_playedSecs = positionSecs;
|
||||
if (!isEnabled() || m_scrobbled || m_title.isEmpty() || m_duration < 30) return;
|
||||
// Scrobble after 50% or 240 seconds played, whichever comes first, min 30 seconds.
|
||||
const quint64 threshold = static_cast<quint64>(qMin((qint64)240, m_duration / 2));
|
||||
if (positionSecs >= 30 && positionSecs >= threshold)
|
||||
scrobble();
|
||||
}
|
||||
|
||||
void onTrackFinished()
|
||||
{
|
||||
if (!isEnabled() || m_scrobbled || m_title.isEmpty() || m_duration < 30) return;
|
||||
if (m_playedSecs >= 30) scrobble();
|
||||
}
|
||||
|
||||
private:
|
||||
QNetworkAccessManager *m_nam = nullptr;
|
||||
|
||||
QString m_title;
|
||||
QString m_artist;
|
||||
QString m_album;
|
||||
qint64 m_duration = 0;
|
||||
qint64 m_startTime = 0;
|
||||
quint64 m_playedSecs = 0;
|
||||
bool m_scrobbled = false;
|
||||
|
||||
void updateNowPlaying()
|
||||
{
|
||||
QMap<QString,QString> params;
|
||||
params["method"] = QStringLiteral("track.updateNowPlaying");
|
||||
params["api_key"] = AppSettings::instance().lastFmApiKey();
|
||||
params["sk"] = AppSettings::instance().lastFmSessionKey();
|
||||
params["artist"] = m_artist;
|
||||
params["track"] = m_title;
|
||||
params["album"] = m_album;
|
||||
params["duration"] = QString::number(m_duration);
|
||||
params["api_sig"] = buildSig(params, AppSettings::instance().lastFmApiSecret());
|
||||
params["format"] = QStringLiteral("json");
|
||||
|
||||
auto *reply = m_nam->post(apiRequest(), encodeBody(params));
|
||||
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
|
||||
}
|
||||
|
||||
void scrobble()
|
||||
{
|
||||
m_scrobbled = true;
|
||||
QMap<QString,QString> params;
|
||||
params["method"] = QStringLiteral("track.scrobble");
|
||||
params["api_key"] = AppSettings::instance().lastFmApiKey();
|
||||
params["sk"] = AppSettings::instance().lastFmSessionKey();
|
||||
params["artist"] = m_artist;
|
||||
params["track"] = m_title;
|
||||
params["album"] = m_album;
|
||||
params["timestamp"] = QString::number(m_startTime);
|
||||
params["duration"] = QString::number(m_duration);
|
||||
params["api_sig"] = buildSig(params, AppSettings::instance().lastFmApiSecret());
|
||||
params["format"] = QStringLiteral("json");
|
||||
|
||||
auto *reply = m_nam->post(apiRequest(), encodeBody(params));
|
||||
connect(reply, &QNetworkReply::finished, reply, &QNetworkReply::deleteLater);
|
||||
}
|
||||
|
||||
/// Build the Last.fm API signature: sort params, concatenate key+value, append secret, md5.
|
||||
static QString buildSig(const QMap<QString,QString> ¶ms, const QString &secret)
|
||||
{
|
||||
QString s;
|
||||
for (auto it = params.constBegin(); it != params.constEnd(); ++it) {
|
||||
if (it.key() != "format" && it.key() != "api_sig")
|
||||
s += it.key() + it.value();
|
||||
}
|
||||
s += secret;
|
||||
return QCryptographicHash::hash(s.toUtf8(), QCryptographicHash::Md5).toHex();
|
||||
}
|
||||
|
||||
static QNetworkRequest apiRequest()
|
||||
{
|
||||
QNetworkRequest req(QUrl(QStringLiteral("https://ws.audioscrobbler.com/2.0/")));
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader,
|
||||
QStringLiteral("application/x-www-form-urlencoded"));
|
||||
return req;
|
||||
}
|
||||
|
||||
static QByteArray encodeBody(const QMap<QString,QString> ¶ms)
|
||||
{
|
||||
QUrlQuery q;
|
||||
for (auto it = params.constBegin(); it != params.constEnd(); ++it)
|
||||
q.addQueryItem(it.key(), it.value());
|
||||
return q.toString(QUrl::FullyEncoded).toUtf8();
|
||||
}
|
||||
};
|
||||
@@ -31,6 +31,19 @@ public:
|
||||
bool rememberLogin() const { return m_settings.value("auth/remember", true).toBool(); }
|
||||
void setRememberLogin(bool r) { m_settings.setValue("auth/remember", r); }
|
||||
|
||||
// --- Last.fm ---
|
||||
bool lastFmEnabled() const { return m_settings.value("lastfm/enabled", false).toBool(); }
|
||||
void setLastFmEnabled(bool v) { m_settings.setValue("lastfm/enabled", v); }
|
||||
|
||||
QString lastFmApiKey() const { return m_settings.value("lastfm/api_key").toString(); }
|
||||
void setLastFmApiKey(const QString &v) { m_settings.setValue("lastfm/api_key", v); }
|
||||
|
||||
QString lastFmApiSecret() const { return m_settings.value("lastfm/api_secret").toString(); }
|
||||
void setLastFmApiSecret(const QString &v) { m_settings.setValue("lastfm/api_secret", v); }
|
||||
|
||||
QString lastFmSessionKey() const { return m_settings.value("lastfm/session_key").toString(); }
|
||||
void setLastFmSessionKey(const QString &v){ m_settings.setValue("lastfm/session_key", v); }
|
||||
|
||||
private:
|
||||
AppSettings() : m_settings("qobuz-qt", "qobuz-qt") {}
|
||||
QSettings m_settings;
|
||||
|
||||
75
src/view/albumlistview.hpp
Normal file
75
src/view/albumlistview.hpp
Normal file
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
#include <QTreeWidget>
|
||||
#include <QTreeWidgetItem>
|
||||
#include <QHeaderView>
|
||||
#include <QFont>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
|
||||
/// A simple list of albums (used for fav albums and artist detail pages).
|
||||
/// Double-clicking an item emits albumSelected(albumId).
|
||||
/// Column 0 shows a small gold "H" for hi-res streamable albums.
|
||||
class AlbumListView : public QTreeWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit AlbumListView(QWidget *parent = nullptr) : QTreeWidget(parent)
|
||||
{
|
||||
setColumnCount(5);
|
||||
setHeaderLabels({tr(""), tr("Title"), tr("Artist"), tr("Year"), tr("Tracks")});
|
||||
setRootIsDecorated(false);
|
||||
setAlternatingRowColors(true);
|
||||
setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
setSortingEnabled(true);
|
||||
|
||||
header()->setStretchLastSection(false);
|
||||
header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); // H column
|
||||
header()->setSectionResizeMode(1, QHeaderView::Stretch);
|
||||
header()->setSectionResizeMode(2, QHeaderView::Stretch);
|
||||
header()->setSectionResizeMode(3, QHeaderView::ResizeToContents);
|
||||
header()->setSectionResizeMode(4, QHeaderView::ResizeToContents);
|
||||
|
||||
connect(this, &QTreeWidget::itemDoubleClicked,
|
||||
this, [this](QTreeWidgetItem *item, int) {
|
||||
const QString id = item->data(1, Qt::UserRole).toString();
|
||||
if (!id.isEmpty()) emit albumSelected(id);
|
||||
});
|
||||
}
|
||||
|
||||
void setAlbums(const QJsonArray &albums)
|
||||
{
|
||||
clear();
|
||||
QFont hiResFont;
|
||||
hiResFont.setBold(true);
|
||||
hiResFont.setPointSizeF(hiResFont.pointSizeF() * 0.85);
|
||||
|
||||
for (const auto &v : albums) {
|
||||
const QJsonObject a = v.toObject();
|
||||
const QString id = a["id"].toString();
|
||||
const QString title = a["title"].toString();
|
||||
const QString artist = a["artist"].toObject()["name"].toString();
|
||||
const QString date = a["release_date_original"].toString();
|
||||
const QString year = date.left(4);
|
||||
const int tracks = a["tracks_count"].toInt();
|
||||
const bool hiRes = a["hires_streamable"].toBool();
|
||||
|
||||
auto *item = new QTreeWidgetItem(this);
|
||||
if (hiRes) {
|
||||
item->setText(0, QStringLiteral("H"));
|
||||
item->setForeground(0, QColor(QStringLiteral("#FFD700")));
|
||||
item->setFont(0, hiResFont);
|
||||
item->setTextAlignment(0, Qt::AlignCenter);
|
||||
}
|
||||
item->setText(1, title);
|
||||
item->setText(2, artist);
|
||||
item->setText(3, year);
|
||||
item->setText(4, tracks > 0 ? QString::number(tracks) : QString());
|
||||
item->setData(1, Qt::UserRole, id);
|
||||
}
|
||||
}
|
||||
|
||||
signals:
|
||||
void albumSelected(const QString &albumId);
|
||||
};
|
||||
53
src/view/artistlistview.hpp
Normal file
53
src/view/artistlistview.hpp
Normal file
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
#include <QTreeWidget>
|
||||
#include <QTreeWidgetItem>
|
||||
#include <QHeaderView>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
|
||||
/// A simple list of artists.
|
||||
/// Double-clicking an item emits artistSelected(artistId).
|
||||
class ArtistListView : public QTreeWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ArtistListView(QWidget *parent = nullptr) : QTreeWidget(parent)
|
||||
{
|
||||
setColumnCount(2);
|
||||
setHeaderLabels({tr("Artist"), tr("Albums")});
|
||||
setRootIsDecorated(false);
|
||||
setAlternatingRowColors(true);
|
||||
setSelectionBehavior(QAbstractItemView::SelectRows);
|
||||
|
||||
header()->setStretchLastSection(false);
|
||||
header()->setSectionResizeMode(0, QHeaderView::Stretch);
|
||||
header()->setSectionResizeMode(1, QHeaderView::ResizeToContents);
|
||||
|
||||
connect(this, &QTreeWidget::itemDoubleClicked,
|
||||
this, [this](QTreeWidgetItem *item, int) {
|
||||
const qint64 id = item->data(0, Qt::UserRole).toLongLong();
|
||||
if (id > 0) emit artistSelected(id);
|
||||
});
|
||||
}
|
||||
|
||||
void setArtists(const QJsonArray &artists)
|
||||
{
|
||||
clear();
|
||||
for (const auto &v : artists) {
|
||||
const QJsonObject a = v.toObject();
|
||||
const qint64 id = static_cast<qint64>(a["id"].toDouble());
|
||||
const QString name = a["name"].toString();
|
||||
const int albums = a["albums_count"].toInt();
|
||||
|
||||
auto *item = new QTreeWidgetItem(this);
|
||||
item->setText(0, name);
|
||||
item->setText(1, albums > 0 ? QString::number(albums) : QString());
|
||||
item->setData(0, Qt::UserRole, id);
|
||||
}
|
||||
}
|
||||
|
||||
signals:
|
||||
void artistSelected(qint64 artistId);
|
||||
};
|
||||
64
src/view/artistview.hpp
Normal file
64
src/view/artistview.hpp
Normal file
@@ -0,0 +1,64 @@
|
||||
#pragma once
|
||||
|
||||
#include "albumlistview.hpp"
|
||||
|
||||
#include <QWidget>
|
||||
#include <QVBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QFont>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
|
||||
/// Artist detail page: name, biography summary, and their album list.
|
||||
class ArtistView : public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ArtistView(QWidget *parent = nullptr) : QWidget(parent)
|
||||
{
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
layout->setContentsMargins(8, 8, 8, 8);
|
||||
layout->setSpacing(6);
|
||||
|
||||
m_nameLabel = new QLabel(this);
|
||||
QFont f = m_nameLabel->font();
|
||||
f.setPointSize(f.pointSize() + 4);
|
||||
f.setBold(true);
|
||||
m_nameLabel->setFont(f);
|
||||
|
||||
m_bioLabel = new QLabel(this);
|
||||
m_bioLabel->setWordWrap(true);
|
||||
m_bioLabel->setAlignment(Qt::AlignTop | Qt::AlignLeft);
|
||||
m_bioLabel->setMaximumHeight(80);
|
||||
|
||||
m_albums = new AlbumListView(this);
|
||||
|
||||
layout->addWidget(m_nameLabel);
|
||||
layout->addWidget(m_bioLabel);
|
||||
layout->addWidget(m_albums, 1);
|
||||
|
||||
connect(m_albums, &AlbumListView::albumSelected,
|
||||
this, &ArtistView::albumSelected);
|
||||
}
|
||||
|
||||
void setArtist(const QJsonObject &artist)
|
||||
{
|
||||
m_nameLabel->setText(artist["name"].toString());
|
||||
|
||||
const QString summary = artist["biography"].toObject()["summary"].toString();
|
||||
m_bioLabel->setText(summary);
|
||||
m_bioLabel->setVisible(!summary.isEmpty());
|
||||
|
||||
const QJsonArray albums = artist["albums"].toObject()["items"].toArray();
|
||||
m_albums->setAlbums(albums);
|
||||
}
|
||||
|
||||
signals:
|
||||
void albumSelected(const QString &albumId);
|
||||
|
||||
private:
|
||||
QLabel *m_nameLabel = nullptr;
|
||||
QLabel *m_bioLabel = nullptr;
|
||||
AlbumListView *m_albums = nullptr;
|
||||
};
|
||||
@@ -19,12 +19,22 @@ MainContent::MainContent(QobuzBackend *backend, PlayQueue *queue, QWidget *paren
|
||||
this);
|
||||
m_welcome->setAlignment(Qt::AlignCenter);
|
||||
|
||||
m_tracks = new List::Tracks(m_backend, queue, this);
|
||||
m_tracks = new List::Tracks(m_backend, queue, this);
|
||||
m_albumList = new AlbumListView(this);
|
||||
m_artistList = new ArtistListView(this);
|
||||
m_artistView = new ArtistView(this);
|
||||
|
||||
m_stack->addWidget(m_welcome); // 0
|
||||
m_stack->addWidget(m_tracks); // 1
|
||||
m_stack->addWidget(m_welcome); // 0
|
||||
m_stack->addWidget(m_tracks); // 1
|
||||
m_stack->addWidget(m_albumList); // 2
|
||||
m_stack->addWidget(m_artistList); // 3
|
||||
m_stack->addWidget(m_artistView); // 4
|
||||
|
||||
m_stack->setCurrentIndex(0);
|
||||
|
||||
connect(m_albumList, &AlbumListView::albumSelected, this, &MainContent::albumRequested);
|
||||
connect(m_artistList, &ArtistListView::artistSelected, this, &MainContent::artistRequested);
|
||||
connect(m_artistView, &ArtistView::albumSelected, this, &MainContent::albumRequested);
|
||||
}
|
||||
|
||||
void MainContent::showWelcome() { m_stack->setCurrentIndex(0); }
|
||||
@@ -52,3 +62,21 @@ void MainContent::showSearchTracks(const QJsonArray &tracks)
|
||||
m_tracks->loadSearchTracks(tracks);
|
||||
m_stack->setCurrentIndex(1);
|
||||
}
|
||||
|
||||
void MainContent::showFavAlbums(const QJsonObject &result)
|
||||
{
|
||||
m_albumList->setAlbums(result["items"].toArray());
|
||||
m_stack->setCurrentIndex(2);
|
||||
}
|
||||
|
||||
void MainContent::showFavArtists(const QJsonObject &result)
|
||||
{
|
||||
m_artistList->setArtists(result["items"].toArray());
|
||||
m_stack->setCurrentIndex(3);
|
||||
}
|
||||
|
||||
void MainContent::showArtist(const QJsonObject &artist)
|
||||
{
|
||||
m_artistView->setArtist(artist);
|
||||
m_stack->setCurrentIndex(4);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
#include "../list/tracks.hpp"
|
||||
#include "../backend/qobuzbackend.hpp"
|
||||
#include "../playqueue.hpp"
|
||||
#include "albumlistview.hpp"
|
||||
#include "artistlistview.hpp"
|
||||
#include "artistview.hpp"
|
||||
|
||||
#include <QWidget>
|
||||
#include <QLabel>
|
||||
@@ -24,10 +27,20 @@ public:
|
||||
void showPlaylist(const QJsonObject &playlist);
|
||||
void showFavTracks(const QJsonObject &result);
|
||||
void showSearchTracks(const QJsonArray &tracks);
|
||||
void showFavAlbums(const QJsonObject &result);
|
||||
void showFavArtists(const QJsonObject &result);
|
||||
void showArtist(const QJsonObject &artist);
|
||||
|
||||
signals:
|
||||
void albumRequested(const QString &albumId);
|
||||
void artistRequested(qint64 artistId);
|
||||
|
||||
private:
|
||||
QobuzBackend *m_backend = nullptr;
|
||||
QStackedWidget *m_stack = nullptr;
|
||||
QLabel *m_welcome = nullptr;
|
||||
List::Tracks *m_tracks = nullptr;
|
||||
QobuzBackend *m_backend = nullptr;
|
||||
QStackedWidget *m_stack = nullptr;
|
||||
QLabel *m_welcome = nullptr;
|
||||
List::Tracks *m_tracks = nullptr;
|
||||
AlbumListView *m_albumList = nullptr;
|
||||
ArtistListView *m_artistList = nullptr;
|
||||
ArtistView *m_artistView = nullptr;
|
||||
};
|
||||
|
||||
@@ -177,6 +177,11 @@ void MainToolBar::onNext()
|
||||
void MainToolBar::onProgressReleased()
|
||||
{
|
||||
m_seeking = false;
|
||||
const quint64 dur = m_backend->duration();
|
||||
if (dur > 0) {
|
||||
const quint64 target = dur * static_cast<quint64>(m_progress->value()) / 1000;
|
||||
m_backend->seek(target);
|
||||
}
|
||||
}
|
||||
|
||||
void MainToolBar::onVolumeChanged(int volume)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
static constexpr int UpcomingIndexRole = Qt::UserRole + 1;
|
||||
static constexpr int IsPlayNextRole = Qt::UserRole + 2;
|
||||
static constexpr int TrackJsonRole = Qt::UserRole + 3;
|
||||
|
||||
QueuePanel::QueuePanel(PlayQueue *queue, QWidget *parent)
|
||||
: QDockWidget(tr("Queue"), parent)
|
||||
@@ -32,6 +33,8 @@ QueuePanel::QueuePanel(PlayQueue *queue, QWidget *parent)
|
||||
m_list = new QListWidget(container);
|
||||
m_list->setAlternatingRowColors(true);
|
||||
m_list->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||
m_list->setDragDropMode(QAbstractItemView::InternalMove);
|
||||
m_list->setDefaultDropAction(Qt::MoveAction);
|
||||
layout->addWidget(m_list, 1);
|
||||
|
||||
setWidget(container);
|
||||
@@ -45,12 +48,17 @@ QueuePanel::QueuePanel(PlayQueue *queue, QWidget *parent)
|
||||
this, &QueuePanel::onItemDoubleClicked);
|
||||
connect(m_list, &QListWidget::customContextMenuRequested,
|
||||
this, &QueuePanel::onContextMenu);
|
||||
connect(m_list->model(), &QAbstractItemModel::rowsMoved,
|
||||
this, &QueuePanel::onRowsMoved);
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
void QueuePanel::refresh()
|
||||
{
|
||||
if (m_refreshing) return;
|
||||
m_refreshing = true;
|
||||
|
||||
m_list->clear();
|
||||
|
||||
const QVector<QJsonObject> upcoming = m_queue->upcomingTracks();
|
||||
@@ -73,6 +81,7 @@ void QueuePanel::refresh()
|
||||
auto *item = new QListWidgetItem(text, m_list);
|
||||
item->setData(UpcomingIndexRole, i);
|
||||
item->setData(IsPlayNextRole, i < playNextCount);
|
||||
item->setData(TrackJsonRole, QVariant::fromValue(t));
|
||||
|
||||
// "Play Next" tracks shown slightly differently
|
||||
if (i < playNextCount) {
|
||||
@@ -81,12 +90,33 @@ void QueuePanel::refresh()
|
||||
item->setFont(f);
|
||||
}
|
||||
}
|
||||
|
||||
m_refreshing = false;
|
||||
}
|
||||
|
||||
void QueuePanel::onItemDoubleClicked(QListWidgetItem *item)
|
||||
{
|
||||
// Double-clicking an upcoming item is not needed for now (could skip to it later)
|
||||
Q_UNUSED(item)
|
||||
const int idx = item->data(UpcomingIndexRole).toInt();
|
||||
const QJsonObject track = m_queue->skipToUpcoming(idx);
|
||||
if (track.isEmpty()) return;
|
||||
const qint64 id = static_cast<qint64>(track["id"].toDouble());
|
||||
emit skipToTrackRequested(id);
|
||||
}
|
||||
|
||||
void QueuePanel::onRowsMoved()
|
||||
{
|
||||
if (m_refreshing) return;
|
||||
|
||||
QVector<QJsonObject> newOrder;
|
||||
newOrder.reserve(m_list->count());
|
||||
for (int i = 0; i < m_list->count(); ++i) {
|
||||
const QVariant v = m_list->item(i)->data(TrackJsonRole);
|
||||
newOrder.append(v.value<QJsonObject>());
|
||||
}
|
||||
|
||||
m_refreshing = true;
|
||||
m_queue->setUpcomingOrder(newOrder);
|
||||
m_refreshing = false;
|
||||
}
|
||||
|
||||
void QueuePanel::onContextMenu(const QPoint &pos)
|
||||
|
||||
@@ -14,14 +14,19 @@ class QueuePanel : public QDockWidget
|
||||
public:
|
||||
explicit QueuePanel(PlayQueue *queue, QWidget *parent = nullptr);
|
||||
|
||||
signals:
|
||||
void skipToTrackRequested(qint64 trackId);
|
||||
|
||||
private slots:
|
||||
void refresh();
|
||||
void onItemDoubleClicked(QListWidgetItem *item);
|
||||
void onContextMenu(const QPoint &pos);
|
||||
void onRowsMoved();
|
||||
|
||||
private:
|
||||
PlayQueue *m_queue = nullptr;
|
||||
QLabel *m_countLabel = nullptr;
|
||||
QListWidget *m_list = nullptr;
|
||||
QPushButton *m_clearBtn = nullptr;
|
||||
bool m_refreshing = false;
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QPushButton>
|
||||
#include <QHeaderView>
|
||||
#include <QFont>
|
||||
#include <QJsonArray>
|
||||
|
||||
static constexpr int IdRole = Qt::UserRole + 1;
|
||||
@@ -38,8 +40,12 @@ SearchTab::SearchTab(QobuzBackend *backend, QWidget *parent)
|
||||
m_trackResults->setRootIsDecorated(false);
|
||||
|
||||
m_albumResults = new QTreeWidget(this);
|
||||
m_albumResults->setHeaderLabels({tr("Album"), tr("Artist")});
|
||||
m_albumResults->setHeaderLabels({tr(""), tr("Album"), tr("Artist")});
|
||||
m_albumResults->setRootIsDecorated(false);
|
||||
m_albumResults->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
|
||||
m_albumResults->header()->setSectionResizeMode(1, QHeaderView::Stretch);
|
||||
m_albumResults->header()->setSectionResizeMode(2, QHeaderView::Stretch);
|
||||
m_albumResults->header()->setStretchLastSection(false);
|
||||
|
||||
m_artistResults = new QTreeWidget(this);
|
||||
m_artistResults->setHeaderLabels({tr("Artist")});
|
||||
@@ -84,14 +90,28 @@ void SearchTab::onSearchResult(const QJsonObject &result)
|
||||
|
||||
// Populate albums
|
||||
m_albumResults->clear();
|
||||
const QJsonArray albums = result["albums"].toObject()["items"].toArray();
|
||||
for (const auto &v : albums) {
|
||||
const QJsonObject a = v.toObject();
|
||||
const QString artist = a["artist"].toObject()["name"].toString();
|
||||
auto *item = new QTreeWidgetItem(m_albumResults,
|
||||
QStringList{a["title"].toString(), artist});
|
||||
item->setData(0, IdRole, a["id"].toString());
|
||||
item->setData(0, TypeRole, QStringLiteral("album"));
|
||||
{
|
||||
QFont hiResFont;
|
||||
hiResFont.setBold(true);
|
||||
hiResFont.setPointSizeF(hiResFont.pointSizeF() * 0.85);
|
||||
|
||||
const QJsonArray albums = result["albums"].toObject()["items"].toArray();
|
||||
for (const auto &v : albums) {
|
||||
const QJsonObject a = v.toObject();
|
||||
const QString artist = a["artist"].toObject()["name"].toString();
|
||||
const bool hiRes = a["hires_streamable"].toBool();
|
||||
|
||||
auto *item = new QTreeWidgetItem(m_albumResults,
|
||||
QStringList{QString(), a["title"].toString(), artist});
|
||||
if (hiRes) {
|
||||
item->setText(0, QStringLiteral("H"));
|
||||
item->setForeground(0, QColor(QStringLiteral("#FFD700")));
|
||||
item->setFont(0, hiResFont);
|
||||
item->setTextAlignment(0, Qt::AlignCenter);
|
||||
}
|
||||
item->setData(0, TypeRole, QStringLiteral("album")); // handler reads col 0
|
||||
item->setData(1, IdRole, a["id"].toString());
|
||||
}
|
||||
}
|
||||
|
||||
// Populate artists
|
||||
@@ -114,7 +134,7 @@ void SearchTab::onItemDoubleClicked(QTreeWidgetItem *item, int)
|
||||
if (type == QStringLiteral("track")) {
|
||||
emit trackPlayRequested(item->data(0, IdRole).toLongLong());
|
||||
} else if (type == QStringLiteral("album")) {
|
||||
emit albumSelected(item->data(0, IdRole).toString());
|
||||
emit albumSelected(item->data(1, IdRole).toString());
|
||||
} else if (type == QStringLiteral("artist")) {
|
||||
emit artistSelected(item->data(0, IdRole).toLongLong());
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ public:
|
||||
|
||||
auto *menu = new QMenu(this);
|
||||
auto *widget = new QWidget(menu);
|
||||
widget->setMinimumWidth(72);
|
||||
auto *layout = new QVBoxLayout(widget);
|
||||
layout->setContentsMargins(6, 6, 6, 6);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user