Compare commits
33 Commits
copilot/fi
...
copilot/ad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2afa8fd66 | ||
|
|
4da4c9e267 | ||
|
|
02c1596ff4 | ||
|
|
597f983c92 | ||
|
|
0e0117b19b | ||
|
|
4e0cc1c0da | ||
|
|
f77e92d634 | ||
|
|
f25cdb3431 | ||
|
|
604dd2dbde | ||
|
|
25065ba98f | ||
|
|
7b16ec62bb | ||
|
|
d8f31592b9 | ||
|
|
80bb0f476d | ||
|
|
b7222ac85c | ||
|
|
241bca0828 | ||
|
|
90d86b10a3 | ||
|
|
4130c6670f | ||
|
|
8d262959c1 | ||
|
|
b9b70399d8 | ||
|
|
527ccd212a | ||
|
|
4a5afbeb1e | ||
|
|
63c14e014b | ||
|
|
801658c6b9 | ||
|
|
16fe665295 | ||
|
|
2bb0dbada2 | ||
|
|
2cd9498469 | ||
|
|
d1ee27fff9 | ||
|
|
91adf5ba32 | ||
|
|
d68f464269 | ||
|
|
c684a95f89 | ||
|
|
1d03bb2178 | ||
|
|
39f9128ecf | ||
|
|
ca2e802239 |
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
@@ -739,12 +739,18 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
- name: Free disk space
|
||||
run: |
|
||||
df -h
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
|
||||
sudo apt-get clean
|
||||
df -h
|
||||
- name: Build FreeBSD
|
||||
id: build-freebsd
|
||||
uses: vmactions/freebsd-vm@v1.3.0
|
||||
uses: vmactions/freebsd-vm@v1.3.2
|
||||
with:
|
||||
usesh: true
|
||||
mem: 4096
|
||||
mem: 8192
|
||||
prepare: pkg install -y git cmake pkgconf boost-libs alsa-lib glib qt6-base qt6-tools sqlite gstreamer1 gstreamer1-plugins chromaprint libebur128 taglib libcdio libmtp gdk-pixbuf2 libgpod fftw3 icu kdsingleapplication googletest pulseaudio sparsehash rapidjson
|
||||
run: |
|
||||
set -e
|
||||
@@ -766,7 +772,7 @@ jobs:
|
||||
submodules: recursive
|
||||
- name: Build OpenBSD
|
||||
id: build-openbsd
|
||||
uses: vmactions/openbsd-vm@v1.2.5
|
||||
uses: vmactions/openbsd-vm@v1.2.9
|
||||
with:
|
||||
usesh: true
|
||||
mem: 4096
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@
|
||||
/CMakeSettings.json
|
||||
/dist/scripts/maketarball.sh
|
||||
/debian/changelog
|
||||
_codeql_detected_source_root
|
||||
|
||||
@@ -295,6 +295,12 @@ if(UNIX AND NOT APPLE)
|
||||
)
|
||||
endif()
|
||||
|
||||
if(MSVC)
|
||||
optional_component(WINDOWS_MEDIA_CONTROLS ON "Windows Media Transport Controls"
|
||||
DEPENDS "MSVC compiler" MSVC
|
||||
)
|
||||
endif()
|
||||
|
||||
optional_component(SONGFINGERPRINTING ON "Song fingerprinting and tracking"
|
||||
DEPENDS "chromaprint" CHROMAPRINT_FOUND
|
||||
)
|
||||
@@ -1294,6 +1300,7 @@ endif()
|
||||
optional_source(HAVE_ALSA SOURCES src/engine/alsadevicefinder.cpp src/engine/alsapcmdevicefinder.cpp)
|
||||
optional_source(HAVE_PULSE SOURCES src/engine/pulsedevicefinder.cpp)
|
||||
optional_source(MSVC SOURCES src/engine/uwpdevicefinder.cpp src/engine/asiodevicefinder.cpp)
|
||||
optional_source(HAVE_WINDOWS_MEDIA_CONTROLS SOURCES src/core/windowsmediacontroller.cpp HEADERS src/core/windowsmediacontroller.h)
|
||||
optional_source(HAVE_CHROMAPRINT SOURCES src/engine/chromaprinter.cpp)
|
||||
|
||||
optional_source(HAVE_MUSICBRAINZ
|
||||
@@ -1463,6 +1470,7 @@ optional_source(HAVE_QOBUZ
|
||||
src/qobuz/qobuzrequest.cpp
|
||||
src/qobuz/qobuzstreamurlrequest.cpp
|
||||
src/qobuz/qobuzfavoriterequest.cpp
|
||||
src/qobuz/qobuzcredentialfetcher.cpp
|
||||
src/settings/qobuzsettingspage.cpp
|
||||
src/covermanager/qobuzcoverprovider.cpp
|
||||
HEADERS
|
||||
@@ -1472,6 +1480,7 @@ optional_source(HAVE_QOBUZ
|
||||
src/qobuz/qobuzrequest.h
|
||||
src/qobuz/qobuzstreamurlrequest.h
|
||||
src/qobuz/qobuzfavoriterequest.h
|
||||
src/qobuz/qobuzcredentialfetcher.h
|
||||
src/settings/qobuzsettingspage.h
|
||||
src/covermanager/qobuzcoverprovider.h
|
||||
UI
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
.
|
||||
@@ -2,7 +2,7 @@
|
||||
* Strawberry Music Player
|
||||
* This file was part of Clementine.
|
||||
* Copyright 2010, David Sansome <me@davidsansome.com>
|
||||
* Copyright 2018-2021, Jonas Kvinge <jonas@jkvinge.net>
|
||||
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
|
||||
*
|
||||
* Strawberry is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -189,6 +189,26 @@ void CollectionLibrary::ReloadSettings() {
|
||||
|
||||
}
|
||||
|
||||
void CollectionLibrary::CurrentSongChanged(const Song &song) {
|
||||
|
||||
current_song_url_ = song.url();
|
||||
|
||||
if (!pending_song_saves_.isEmpty()) {
|
||||
SavePendingPlaycountsAndRatings();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void CollectionLibrary::Stopped() {
|
||||
|
||||
current_song_url_ = QUrl();
|
||||
|
||||
if (!pending_song_saves_.isEmpty()) {
|
||||
SavePendingPlaycountsAndRatings();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void CollectionLibrary::SyncPlaycountAndRatingToFilesAsync() {
|
||||
|
||||
(void)QtConcurrent::run(&CollectionLibrary::SyncPlaycountAndRatingToFiles, this);
|
||||
@@ -212,18 +232,85 @@ void CollectionLibrary::SyncPlaycountAndRatingToFiles() {
|
||||
|
||||
}
|
||||
|
||||
void CollectionLibrary::SongsPlaycountChanged(const SongList &songs, const bool save_tags) const {
|
||||
void CollectionLibrary::SongsPlaycountChanged(const SongList &songs, const bool save_tags) {
|
||||
|
||||
if (save_tags || save_playcounts_to_files_) {
|
||||
tagreader_client_->SaveSongsPlaycountAsync(songs);
|
||||
SongList songs_to_save_now;
|
||||
for (const Song &song : songs) {
|
||||
if (song.url().isLocalFile() && song.url() == current_song_url_ &&
|
||||
(song.filetype() == Song::FileType::OggFlac || song.filetype() == Song::FileType::OggVorbis || song.filetype() == Song::FileType::OggOpus)) {
|
||||
qLog(Debug) << "Deferring playcount save for currently playing file" << song.url().toLocalFile();
|
||||
if (pending_song_saves_.contains(song.url())) {
|
||||
SharedPtr<PendingSongSave> pending_song_save = pending_song_saves_[song.url()];
|
||||
pending_song_save->save_playcount = true;
|
||||
pending_song_save->song.set_playcount(song.playcount());
|
||||
}
|
||||
else {
|
||||
SharedPtr<PendingSongSave> pending_song_save = make_shared<PendingSongSave>();
|
||||
pending_song_save->save_playcount = true;
|
||||
pending_song_save->song = song;
|
||||
pending_song_saves_.insert(song.url(), pending_song_save);
|
||||
}
|
||||
}
|
||||
else {
|
||||
songs_to_save_now << song;
|
||||
}
|
||||
}
|
||||
if (!songs_to_save_now.isEmpty()) {
|
||||
tagreader_client_->SaveSongsPlaycountAsync(songs_to_save_now);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void CollectionLibrary::SongsRatingChanged(const SongList &songs, const bool save_tags) const {
|
||||
void CollectionLibrary::SongsRatingChanged(const SongList &songs, const bool save_tags) {
|
||||
|
||||
if (save_tags || save_ratings_to_files_) {
|
||||
tagreader_client_->SaveSongsRatingAsync(songs);
|
||||
SongList songs_to_save_now;
|
||||
for (const Song &song : songs) {
|
||||
if (song.url().isLocalFile() && song.url() == current_song_url_ &&
|
||||
(song.filetype() == Song::FileType::OggFlac || song.filetype() == Song::FileType::OggVorbis || song.filetype() == Song::FileType::OggOpus)) {
|
||||
qLog(Debug) << "Deferring rating save for currently playing file" << song.url().toLocalFile();
|
||||
if (pending_song_saves_.contains(song.url())) {
|
||||
SharedPtr<PendingSongSave> pending_song_save = pending_song_saves_[song.url()];
|
||||
pending_song_save->save_rating = true;
|
||||
pending_song_save->song.set_rating(song.rating());
|
||||
}
|
||||
else {
|
||||
SharedPtr<PendingSongSave> pending_song_save = make_shared<PendingSongSave>();
|
||||
pending_song_save->save_rating = true;
|
||||
pending_song_save->song = song;
|
||||
pending_song_saves_.insert(song.url(), pending_song_save);
|
||||
}
|
||||
}
|
||||
else {
|
||||
songs_to_save_now << song;
|
||||
}
|
||||
}
|
||||
if (!songs_to_save_now.isEmpty()) {
|
||||
tagreader_client_->SaveSongsRatingAsync(songs_to_save_now);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void CollectionLibrary::SavePendingPlaycountsAndRatings() {
|
||||
|
||||
for (auto it = pending_song_saves_.constBegin(); it != pending_song_saves_.constEnd();) {
|
||||
const QUrl url = it.key();
|
||||
SharedPtr<PendingSongSave> pending_song_save = it.value();
|
||||
if (url == current_song_url_) {
|
||||
++it;
|
||||
continue;
|
||||
}
|
||||
qLog(Debug) << "Saving deferred playcount/rating for" << url.toLocalFile();
|
||||
if (pending_song_save->save_playcount) {
|
||||
tagreader_client_->SaveSongsPlaycountAsync(SongList() << pending_song_save->song);
|
||||
}
|
||||
if (pending_song_save->save_rating) {
|
||||
tagreader_client_->SaveSongsRatingAsync(SongList() << pending_song_save->song);
|
||||
}
|
||||
it = pending_song_saves_.erase(it);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Strawberry Music Player
|
||||
* This file was part of Clementine.
|
||||
* Copyright 2010, David Sansome <me@davidsansome.com>
|
||||
* Copyright 2018-2021, Jonas Kvinge <jonas@jkvinge.net>
|
||||
* Copyright 2018-2025, Jonas Kvinge <jonas@jkvinge.net>
|
||||
*
|
||||
* Strawberry is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
@@ -27,6 +27,7 @@
|
||||
#include <QObject>
|
||||
#include <QList>
|
||||
#include <QHash>
|
||||
#include <QMap>
|
||||
#include <QString>
|
||||
|
||||
#include "includes/shared_ptr.h"
|
||||
@@ -71,6 +72,7 @@ class CollectionLibrary : public QObject {
|
||||
|
||||
private:
|
||||
void SyncPlaycountAndRatingToFiles();
|
||||
void SavePendingPlaycountsAndRatings();
|
||||
|
||||
public Q_SLOTS:
|
||||
void ReloadSettings();
|
||||
@@ -84,16 +86,26 @@ class CollectionLibrary : public QObject {
|
||||
|
||||
void IncrementalScan();
|
||||
|
||||
void CurrentSongChanged(const Song &song);
|
||||
void Stopped();
|
||||
|
||||
private Q_SLOTS:
|
||||
void ExitReceived();
|
||||
void SongsPlaycountChanged(const SongList &songs, const bool save_tags = false) const;
|
||||
void SongsRatingChanged(const SongList &songs, const bool save_tags = false) const;
|
||||
void SongsPlaycountChanged(const SongList &songs, const bool save_tags = false);
|
||||
void SongsRatingChanged(const SongList &songs, const bool save_tags = false);
|
||||
|
||||
Q_SIGNALS:
|
||||
void Error(const QString &error);
|
||||
void ExitFinished();
|
||||
|
||||
private:
|
||||
class PendingSongSave {
|
||||
public:
|
||||
Song song;
|
||||
bool save_playcount = false;
|
||||
bool save_rating = false;
|
||||
};
|
||||
|
||||
const SharedPtr<TaskManager> task_manager_;
|
||||
const SharedPtr<TagReaderClient> tagreader_client_;
|
||||
|
||||
@@ -111,6 +123,10 @@ class CollectionLibrary : public QObject {
|
||||
|
||||
bool save_playcounts_to_files_;
|
||||
bool save_ratings_to_files_;
|
||||
|
||||
QUrl current_song_url_;
|
||||
|
||||
QMap<QUrl, SharedPtr<PendingSongSave>> pending_song_saves_;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1209,49 +1209,41 @@ QString CollectionModel::ContainerKey(const GroupBy group_by, const Song &song,
|
||||
switch (group_by) {
|
||||
case GroupBy::AlbumArtist:
|
||||
key = TextOrUnknown(song.effective_albumartist());
|
||||
if (!song.effective_albumartistsort().isEmpty() && song.effective_albumartistsort() != song.effective_albumartist()) key.append(QLatin1Char('-') + TextOrUnknown(song.effective_albumartistsort()));
|
||||
has_unique_album_identifier = true;
|
||||
break;
|
||||
case GroupBy::Artist:
|
||||
key = TextOrUnknown(song.artist());
|
||||
if (!song.artistsort().isEmpty() && song.artistsort() != song.artist()) key.append(QLatin1Char('-') + TextOrUnknown(song.artistsort()));
|
||||
has_unique_album_identifier = true;
|
||||
break;
|
||||
case GroupBy::Album:
|
||||
key = TextOrUnknown(song.album());
|
||||
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
|
||||
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
|
||||
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
|
||||
break;
|
||||
case GroupBy::AlbumDisc:
|
||||
key = TextOrUnknown(song.album());
|
||||
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
|
||||
key.append(QLatin1Char('-') + SortTextForNumber(song.disc()));
|
||||
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
|
||||
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
|
||||
break;
|
||||
case GroupBy::YearAlbum:
|
||||
key = SortTextForYear(song.year()) + QLatin1Char('-') + TextOrUnknown(song.album());
|
||||
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
|
||||
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
|
||||
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
|
||||
break;
|
||||
case GroupBy::YearAlbumDisc:
|
||||
key = SortTextForYear(song.year()) + QLatin1Char('-') + TextOrUnknown(song.album());
|
||||
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
|
||||
key.append(QLatin1Char('-') + SortTextForNumber(song.disc()));
|
||||
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
|
||||
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
|
||||
break;
|
||||
case GroupBy::OriginalYearAlbum:
|
||||
key = SortTextForYear(song.effective_originalyear()) + QLatin1Char('-') + TextOrUnknown(song.album());
|
||||
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
|
||||
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
|
||||
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
|
||||
break;
|
||||
case GroupBy::OriginalYearAlbumDisc:
|
||||
key = SortTextForYear(song.effective_originalyear()) + QLatin1Char('-') + TextOrUnknown(song.album());
|
||||
if (!song.albumsort().isEmpty() && song.albumsort() != song.album()) key.append(QLatin1Char('-') + TextOrUnknown(song.albumsort()));
|
||||
key.append(QLatin1Char('-') + SortTextForNumber(song.disc()));
|
||||
if (!song.album_id().isEmpty()) key.append(QLatin1Char('-') + song.album_id());
|
||||
if (options_active_.separate_albums_by_grouping && !song.grouping().isEmpty()) key.append(QLatin1Char('-') + song.grouping());
|
||||
@@ -1270,12 +1262,10 @@ QString CollectionModel::ContainerKey(const GroupBy group_by, const Song &song,
|
||||
break;
|
||||
case GroupBy::Composer:
|
||||
key = TextOrUnknown(song.composer());
|
||||
if (!song.composersort().isEmpty() && song.composersort() != song.composer()) key.append(QLatin1Char('-') + song.composersort());
|
||||
has_unique_album_identifier = true;
|
||||
break;
|
||||
case GroupBy::Performer:
|
||||
key = TextOrUnknown(song.performer());
|
||||
if (!song.performersort().isEmpty() && song.performersort() != song.performer()) key.append(QLatin1Char('-') + song.performersort());
|
||||
has_unique_album_identifier = true;
|
||||
break;
|
||||
case GroupBy::Grouping:
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#cmakedefine HAVE_GIO_UNIX
|
||||
#cmakedefine HAVE_DBUS
|
||||
#cmakedefine HAVE_MPRIS2
|
||||
#cmakedefine HAVE_WINDOWS_MEDIA_CONTROLS
|
||||
#cmakedefine HAVE_UDISKS2
|
||||
#cmakedefine HAVE_AUDIOCD
|
||||
#cmakedefine HAVE_MTP
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
#include <QShortcut>
|
||||
#include <QMessageBox>
|
||||
#include <QErrorMessage>
|
||||
#include <QSettings>
|
||||
#include <QColor>
|
||||
#include <QFrame>
|
||||
#include <QItemSelectionModel>
|
||||
@@ -697,6 +696,9 @@ MainWindow::MainWindow(Application *app,
|
||||
QObject::connect(&*app_->task_manager(), &TaskManager::PauseCollectionWatchers, &*app_->collection(), &CollectionLibrary::PauseWatcher);
|
||||
QObject::connect(&*app_->task_manager(), &TaskManager::ResumeCollectionWatchers, &*app_->collection(), &CollectionLibrary::ResumeWatcher);
|
||||
|
||||
QObject::connect(&*app_->playlist_manager(), &PlaylistManager::CurrentSongChanged, &*app_->collection(), &CollectionLibrary::CurrentSongChanged);
|
||||
QObject::connect(&*app_->player(), &Player::Stopped, &*app_->collection(), &CollectionLibrary::Stopped);
|
||||
|
||||
QObject::connect(&*app_->playlist_manager(), &PlaylistManager::CurrentSongChanged, &*app_->current_albumcover_loader(), &CurrentAlbumCoverLoader::LoadAlbumCover);
|
||||
QObject::connect(&*app_->current_albumcover_loader(), &CurrentAlbumCoverLoader::AlbumCoverLoaded, this, &MainWindow::AlbumCoverLoaded);
|
||||
QObject::connect(album_cover_choice_controller_, &AlbumCoverChoiceController::Error, this, &MainWindow::ShowErrorDialog);
|
||||
@@ -977,27 +979,28 @@ MainWindow::MainWindow(Application *app,
|
||||
|
||||
// Load settings
|
||||
qLog(Debug) << "Loading settings";
|
||||
settings_.beginGroup(MainWindowSettings::kSettingsGroup);
|
||||
Settings settings;
|
||||
settings.beginGroup(MainWindowSettings::kSettingsGroup);
|
||||
|
||||
// Set last used geometry to position window on the correct monitor
|
||||
// Set window state only if the window was last maximized
|
||||
if (settings_.contains("geometry")) {
|
||||
restoreGeometry(settings_.value("geometry").toByteArray());
|
||||
if (settings.contains("geometry")) {
|
||||
restoreGeometry(settings.value("geometry").toByteArray());
|
||||
}
|
||||
|
||||
if (!settings_.contains(MainWindowSettings::kSplitterState) || !ui_->splitter->restoreState(settings_.value(MainWindowSettings::kSplitterState).toByteArray())) {
|
||||
if (!settings.contains(MainWindowSettings::kSplitterState) || !ui_->splitter->restoreState(settings.value(MainWindowSettings::kSplitterState).toByteArray())) {
|
||||
ui_->splitter->setSizes(QList<int>() << 20 << (width() - 20));
|
||||
}
|
||||
|
||||
ui_->tabs->setCurrentIndex(settings_.value("current_tab", 1).toInt());
|
||||
ui_->tabs->setCurrentIndex(settings.value("current_tab", 1).toInt());
|
||||
FancyTabWidget::Mode default_mode = FancyTabWidget::Mode::LargeSidebar;
|
||||
FancyTabWidget::Mode tab_mode = static_cast<FancyTabWidget::Mode>(settings_.value("tab_mode", static_cast<int>(default_mode)).toInt());
|
||||
FancyTabWidget::Mode tab_mode = static_cast<FancyTabWidget::Mode>(settings.value("tab_mode", static_cast<int>(default_mode)).toInt());
|
||||
if (tab_mode == FancyTabWidget::Mode::None) tab_mode = default_mode;
|
||||
ui_->tabs->SetMode(tab_mode);
|
||||
|
||||
TabSwitched();
|
||||
|
||||
file_view_->SetPath(settings_.value("file_path", QDir::homePath()).toString());
|
||||
file_view_->SetPath(settings.value("file_path", QDir::homePath()).toString());
|
||||
|
||||
// Users often collapse one side of the splitter by mistake and don't know how to restore it. This must be set after the state is restored above.
|
||||
ui_->splitter->setChildrenCollapsible(false);
|
||||
@@ -1040,13 +1043,13 @@ MainWindow::MainWindow(Application *app,
|
||||
case BehaviourSettings::StartupBehaviour::Remember:
|
||||
default:{
|
||||
|
||||
was_maximized_ = settings_.value(MainWindowSettings::kMaximized, true).toBool();
|
||||
was_maximized_ = settings.value(MainWindowSettings::kMaximized, true).toBool();
|
||||
if (was_maximized_) setWindowState(windowState() | Qt::WindowMaximized);
|
||||
|
||||
was_minimized_ = settings_.value(MainWindowSettings::kMinimized, false).toBool();
|
||||
was_minimized_ = settings.value(MainWindowSettings::kMinimized, false).toBool();
|
||||
if (was_minimized_) setWindowState(windowState() | Qt::WindowMinimized);
|
||||
|
||||
if (!systemtrayicon_->IsSystemTrayAvailable() || !systemtrayicon_->isVisible() || !settings_.value(MainWindowSettings::kHidden, false).toBool()) {
|
||||
if (!systemtrayicon_->IsSystemTrayAvailable() || !systemtrayicon_->isVisible() || !settings.value(MainWindowSettings::kHidden, false).toBool()) {
|
||||
show();
|
||||
}
|
||||
break;
|
||||
@@ -1054,7 +1057,7 @@ MainWindow::MainWindow(Application *app,
|
||||
}
|
||||
#endif
|
||||
|
||||
bool show_sidebar = settings_.value(MainWindowSettings::kShowSidebar, true).toBool();
|
||||
bool show_sidebar = settings.value(MainWindowSettings::kShowSidebar, true).toBool();
|
||||
ui_->sidebar_layout->setVisible(show_sidebar);
|
||||
ui_->action_toggle_show_sidebar->setChecked(show_sidebar);
|
||||
|
||||
@@ -1225,7 +1228,9 @@ void MainWindow::ReloadSettings() {
|
||||
|
||||
osd_->ReloadSettings();
|
||||
|
||||
album_cover_choice_controller_->search_cover_auto_action()->setChecked(settings_.value(MainWindowSettings::kSearchForCoverAuto, true).toBool());
|
||||
s.beginGroup(MainWindowSettings::kSettingsGroup);
|
||||
album_cover_choice_controller_->search_cover_auto_action()->setChecked(s.value(MainWindowSettings::kSearchForCoverAuto, true).toBool());
|
||||
s.endGroup();
|
||||
|
||||
#ifdef HAVE_SUBSONIC
|
||||
s.beginGroup(SubsonicSettings::kSettingsGroup);
|
||||
@@ -1342,8 +1347,11 @@ void MainWindow::SaveSettings() {
|
||||
ui_->playlist->view()->SaveSettings();
|
||||
app_->scrobbler()->WriteCache();
|
||||
|
||||
settings_.setValue(MainWindowSettings::kShowSidebar, ui_->action_toggle_show_sidebar->isChecked());
|
||||
settings_.setValue(MainWindowSettings::kSearchForCoverAuto, album_cover_choice_controller_->search_cover_auto_action()->isChecked());
|
||||
Settings s;
|
||||
s.beginGroup(MainWindowSettings::kSettingsGroup);
|
||||
s.setValue(MainWindowSettings::kShowSidebar, ui_->action_toggle_show_sidebar->isChecked());
|
||||
s.setValue(MainWindowSettings::kSearchForCoverAuto, album_cover_choice_controller_->search_cover_auto_action()->isChecked());
|
||||
s.endGroup();
|
||||
|
||||
}
|
||||
|
||||
@@ -1584,23 +1592,35 @@ void MainWindow::ToggleSidebar(const bool checked) {
|
||||
|
||||
ui_->sidebar_layout->setVisible(checked);
|
||||
TabSwitched();
|
||||
settings_.setValue(MainWindowSettings::kShowSidebar, checked);
|
||||
|
||||
Settings s;
|
||||
s.beginGroup(MainWindowSettings::kSettingsGroup);
|
||||
s.setValue(MainWindowSettings::kShowSidebar, checked);
|
||||
s.endGroup();
|
||||
|
||||
}
|
||||
|
||||
void MainWindow::ToggleSearchCoverAuto(const bool checked) {
|
||||
settings_.setValue(MainWindowSettings::kSearchForCoverAuto, checked);
|
||||
|
||||
Settings s;
|
||||
s.beginGroup(MainWindowSettings::kSettingsGroup);
|
||||
s.setValue(MainWindowSettings::kSearchForCoverAuto, checked);
|
||||
s.endGroup();
|
||||
|
||||
}
|
||||
|
||||
void MainWindow::SaveGeometry() {
|
||||
|
||||
if (!initialized_) return;
|
||||
|
||||
settings_.setValue(MainWindowSettings::kMaximized, isMaximized());
|
||||
settings_.setValue(MainWindowSettings::kMinimized, isMinimized());
|
||||
settings_.setValue(MainWindowSettings::kHidden, isHidden());
|
||||
settings_.setValue(MainWindowSettings::kGeometry, saveGeometry());
|
||||
settings_.setValue(MainWindowSettings::kSplitterState, ui_->splitter->saveState());
|
||||
Settings s;
|
||||
s.beginGroup(MainWindowSettings::kSettingsGroup);
|
||||
s.setValue(MainWindowSettings::kMaximized, isMaximized());
|
||||
s.setValue(MainWindowSettings::kMinimized, isMinimized());
|
||||
s.setValue(MainWindowSettings::kHidden, isHidden());
|
||||
s.setValue(MainWindowSettings::kGeometry, saveGeometry());
|
||||
s.setValue(MainWindowSettings::kSplitterState, ui_->splitter->saveState());
|
||||
s.endGroup();
|
||||
|
||||
}
|
||||
|
||||
@@ -1742,7 +1762,12 @@ void MainWindow::SetHiddenInTray(const bool hidden) {
|
||||
}
|
||||
|
||||
void MainWindow::FilePathChanged(const QString &path) {
|
||||
settings_.setValue("file_path", path);
|
||||
|
||||
Settings s;
|
||||
s.beginGroup(MainWindowSettings::kSettingsGroup);
|
||||
s.setValue("file_path", path);
|
||||
s.endGroup();
|
||||
|
||||
}
|
||||
|
||||
void MainWindow::Seeked(const qint64 microseconds) {
|
||||
@@ -2324,7 +2349,9 @@ void MainWindow::EditValue() {
|
||||
void MainWindow::AddFile() {
|
||||
|
||||
// Last used directory
|
||||
QString directory = settings_.value("add_media_path", QDir::currentPath()).toString();
|
||||
Settings s;
|
||||
s.beginGroup(MainWindowSettings::kSettingsGroup);
|
||||
QString directory = s.value("add_media_path", QDir::currentPath()).toString();
|
||||
|
||||
PlaylistParser parser(app_->tagreader_client(), app_->collection_backend());
|
||||
|
||||
@@ -2334,7 +2361,7 @@ void MainWindow::AddFile() {
|
||||
if (filenames.isEmpty()) return;
|
||||
|
||||
// Save last used directory
|
||||
settings_.setValue("add_media_path", filenames[0]);
|
||||
s.setValue("add_media_path", filenames[0]);
|
||||
|
||||
// Convert to URLs
|
||||
QList<QUrl> urls;
|
||||
@@ -2352,14 +2379,16 @@ void MainWindow::AddFile() {
|
||||
void MainWindow::AddFolder() {
|
||||
|
||||
// Last used directory
|
||||
QString directory = settings_.value("add_folder_path", QDir::currentPath()).toString();
|
||||
Settings s;
|
||||
s.beginGroup(MainWindowSettings::kSettingsGroup);
|
||||
QString directory = s.value("add_folder_path", QDir::currentPath()).toString();
|
||||
|
||||
// Show dialog
|
||||
directory = QFileDialog::getExistingDirectory(this, tr("Add folder"), directory);
|
||||
if (directory.isEmpty()) return;
|
||||
|
||||
// Save last used directory
|
||||
settings_.setValue("add_folder_path", directory);
|
||||
s.setValue("add_folder_path", directory);
|
||||
|
||||
// Add media
|
||||
MimeData *mimedata = new MimeData;
|
||||
@@ -3318,7 +3347,7 @@ void MainWindow::PlaylistDelete() {
|
||||
|
||||
if (DeleteConfirmationDialog::warning(files) != QDialogButtonBox::Yes) return;
|
||||
|
||||
if (app_->player()->GetState() == EngineBase::State::Playing && app_->playlist_manager()->current()->rowCount() == selected_songs.count()) {
|
||||
if (app_->player()->GetState() == EngineBase::State::Playing && app_->playlist_manager()->current() == app_->playlist_manager()->active() && app_->playlist_manager()->current()->rowCount() == selected_songs.count()) {
|
||||
app_->player()->Stop();
|
||||
}
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
#include <QImage>
|
||||
#include <QPixmap>
|
||||
#include <QTimer>
|
||||
#include <QSettings>
|
||||
#include <QtEvents>
|
||||
|
||||
#include "includes/scoped_ptr.h"
|
||||
@@ -53,7 +52,6 @@
|
||||
#include "includes/lazy.h"
|
||||
#include "core/platforminterface.h"
|
||||
#include "core/song.h"
|
||||
#include "core/settings.h"
|
||||
#include "core/commandlineoptions.h"
|
||||
#include "tagreader/tagreaderclient.h"
|
||||
#include "osd/osdbase.h"
|
||||
@@ -390,7 +388,6 @@ class MainWindow : public QMainWindow, public PlatformInterface {
|
||||
|
||||
QTimer *track_position_timer_;
|
||||
QTimer *track_slider_timer_;
|
||||
Settings settings_;
|
||||
|
||||
bool keep_running_;
|
||||
bool playing_widget_;
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkRequest>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkInformation>
|
||||
|
||||
#include "networkaccessmanager.h"
|
||||
#include "threadsafenetworkdiskcache.h"
|
||||
@@ -41,6 +42,22 @@ NetworkAccessManager::NetworkAccessManager(QObject *parent)
|
||||
setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
setCache(new ThreadSafeNetworkDiskCache(this));
|
||||
|
||||
// Handle network state changes after system suspend/resume
|
||||
// QNetworkInformation provides cross-platform network reachability monitoring in Qt 6
|
||||
if (QNetworkInformation::loadDefaultBackend()) {
|
||||
QNetworkInformation *network_info = QNetworkInformation::instance();
|
||||
if (network_info) {
|
||||
QObject::connect(network_info, &QNetworkInformation::reachabilityChanged, this, [this](QNetworkInformation::Reachability reachability) {
|
||||
if (reachability == QNetworkInformation::Reachability::Online) {
|
||||
// Clear connection cache to force reconnection after network becomes available
|
||||
// This fixes issues after system suspend/resume
|
||||
clearConnectionCache();
|
||||
clearAccessCache();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
QNetworkReply *NetworkAccessManager::createRequest(Operation op, const QNetworkRequest &network_request, QIODevice *outgoing_data) {
|
||||
|
||||
@@ -353,6 +353,8 @@ struct Song::Private : public QSharedData {
|
||||
std::optional<double> ebur128_integrated_loudness_lufs_;
|
||||
std::optional<double> ebur128_loudness_range_lu_;
|
||||
|
||||
int id3v2_version_; // ID3v2 tag version (3 or 4), 0 if not applicable or unknown
|
||||
|
||||
bool init_from_file_; // Whether this song was loaded from a file using taglib.
|
||||
bool suspicious_tags_; // Whether our encoding guesser thinks these tags might be incorrectly encoded.
|
||||
|
||||
@@ -400,6 +402,8 @@ Song::Private::Private(const Source source)
|
||||
rating_(-1),
|
||||
bpm_(-1),
|
||||
|
||||
id3v2_version_(0),
|
||||
|
||||
init_from_file_(false),
|
||||
suspicious_tags_(false)
|
||||
|
||||
@@ -510,6 +514,8 @@ const QString &Song::musicbrainz_work_id() const { return d->musicbrainz_work_id
|
||||
std::optional<double> Song::ebur128_integrated_loudness_lufs() const { return d->ebur128_integrated_loudness_lufs_; }
|
||||
std::optional<double> Song::ebur128_loudness_range_lu() const { return d->ebur128_loudness_range_lu_; }
|
||||
|
||||
int Song::id3v2_version() const { return d->id3v2_version_; }
|
||||
|
||||
QString *Song::mutable_title() { return &d->title_; }
|
||||
QString *Song::mutable_album() { return &d->album_; }
|
||||
QString *Song::mutable_artist() { return &d->artist_; }
|
||||
@@ -624,6 +630,8 @@ void Song::set_musicbrainz_work_id(const QString &v) { d->musicbrainz_work_id_ =
|
||||
void Song::set_ebur128_integrated_loudness_lufs(const std::optional<double> v) { d->ebur128_integrated_loudness_lufs_ = v; }
|
||||
void Song::set_ebur128_loudness_range_lu(const std::optional<double> v) { d->ebur128_loudness_range_lu_ = v; }
|
||||
|
||||
void Song::set_id3v2_version(const int v) { d->id3v2_version_ = v; }
|
||||
|
||||
void Song::set_init_from_file(const bool v) { d->init_from_file_ = v; }
|
||||
|
||||
void Song::set_stream_url(const QUrl &v) { d->stream_url_ = v; }
|
||||
@@ -797,19 +805,19 @@ bool Song::lyrics_supported() const {
|
||||
}
|
||||
|
||||
bool Song::albumartistsort_supported() const {
|
||||
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::MPEG;
|
||||
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::OggOpus || d->filetype_ == FileType::MPEG;
|
||||
}
|
||||
|
||||
bool Song::albumsort_supported() const {
|
||||
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::MPEG;
|
||||
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::OggOpus || d->filetype_ == FileType::MPEG;
|
||||
}
|
||||
|
||||
bool Song::artistsort_supported() const {
|
||||
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::MPEG;
|
||||
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::OggOpus || d->filetype_ == FileType::MPEG;
|
||||
}
|
||||
|
||||
bool Song::composersort_supported() const {
|
||||
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::MPEG;
|
||||
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::OggOpus || d->filetype_ == FileType::MPEG;
|
||||
}
|
||||
|
||||
bool Song::performersort_supported() const {
|
||||
@@ -818,7 +826,7 @@ bool Song::performersort_supported() const {
|
||||
}
|
||||
|
||||
bool Song::titlesort_supported() const {
|
||||
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::MPEG;
|
||||
return d->filetype_ == FileType::FLAC || d->filetype_ == FileType::OggFlac || d->filetype_ == FileType::OggVorbis || d->filetype_ == FileType::OggOpus || d->filetype_ == FileType::MPEG;
|
||||
}
|
||||
|
||||
bool Song::save_embedded_cover_supported(const FileType filetype) {
|
||||
@@ -833,6 +841,10 @@ bool Song::save_embedded_cover_supported(const FileType filetype) {
|
||||
|
||||
}
|
||||
|
||||
bool Song::id3v2_tags_supported() const {
|
||||
return d->filetype_ == FileType::MPEG || d->filetype_ == FileType::WAV || d->filetype_ == FileType::AIFF;
|
||||
}
|
||||
|
||||
int Song::ColumnIndex(const QString &field) {
|
||||
|
||||
return static_cast<int>(kRowIdColumns.indexOf(field));
|
||||
|
||||
@@ -234,6 +234,8 @@ class Song {
|
||||
std::optional<double> ebur128_integrated_loudness_lufs() const;
|
||||
std::optional<double> ebur128_loudness_range_lu() const;
|
||||
|
||||
int id3v2_version() const;
|
||||
|
||||
QString *mutable_title();
|
||||
QString *mutable_album();
|
||||
QString *mutable_artist();
|
||||
@@ -349,6 +351,8 @@ class Song {
|
||||
void set_ebur128_integrated_loudness_lufs(const std::optional<double> v);
|
||||
void set_ebur128_loudness_range_lu(const std::optional<double> v);
|
||||
|
||||
void set_id3v2_version(const int v);
|
||||
|
||||
void set_init_from_file(const bool v);
|
||||
|
||||
void set_stream_url(const QUrl &v);
|
||||
@@ -439,6 +443,8 @@ class Song {
|
||||
static bool save_embedded_cover_supported(const FileType filetype);
|
||||
bool save_embedded_cover_supported() const { return url().isLocalFile() && save_embedded_cover_supported(filetype()) && !has_cue(); };
|
||||
|
||||
bool id3v2_tags_supported() const;
|
||||
|
||||
static int ColumnIndex(const QString &field);
|
||||
static QString JoinSpec(const QString &table);
|
||||
|
||||
|
||||
303
src/core/windowsmediacontroller.cpp
Normal file
303
src/core/windowsmediacontroller.cpp
Normal file
@@ -0,0 +1,303 @@
|
||||
/*
|
||||
* Strawberry Music Player
|
||||
* Copyright 2025, Jonas Kvinge <jonas@jkvinge.net>
|
||||
*
|
||||
* Strawberry is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Strawberry is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
|
||||
// Undefine 'interface' macro from windows.h before including WinRT headers
|
||||
#pragma push_macro("interface")
|
||||
#undef interface
|
||||
|
||||
#include <winrt/Windows.Foundation.h>
|
||||
#include <winrt/Windows.Media.h>
|
||||
#include <winrt/Windows.Storage.h>
|
||||
#include <winrt/Windows.Storage.Streams.h>
|
||||
|
||||
#pragma pop_macro("interface")
|
||||
|
||||
// Include the interop header for ISystemMediaTransportControlsInterop
|
||||
#include <systemmediatransportcontrolsinterop.h>
|
||||
|
||||
#include "core/logging.h"
|
||||
#include "windowsmediacontroller.h"
|
||||
|
||||
#include "core/song.h"
|
||||
#include "core/player.h"
|
||||
#include "engine/enginebase.h"
|
||||
#include "playlist/playlistmanager.h"
|
||||
#include "covermanager/currentalbumcoverloader.h"
|
||||
#include "covermanager/albumcoverloaderresult.h"
|
||||
|
||||
using namespace winrt;
|
||||
using namespace Windows::Foundation;
|
||||
using namespace Windows::Media;
|
||||
using namespace Windows::Storage;
|
||||
using namespace Windows::Storage::Streams;
|
||||
|
||||
// Helper struct to hold the WinRT object
|
||||
struct WindowsMediaControllerPrivate {
|
||||
SystemMediaTransportControls smtc{nullptr};
|
||||
};
|
||||
|
||||
WindowsMediaController::WindowsMediaController(HWND hwnd,
|
||||
const SharedPtr<Player> player,
|
||||
const SharedPtr<PlaylistManager> playlist_manager,
|
||||
const SharedPtr<CurrentAlbumCoverLoader> current_albumcover_loader,
|
||||
QObject *parent)
|
||||
: QObject(parent),
|
||||
player_(player),
|
||||
playlist_manager_(playlist_manager),
|
||||
current_albumcover_loader_(current_albumcover_loader),
|
||||
smtc_(nullptr),
|
||||
apartment_initialized_(false) {
|
||||
|
||||
try {
|
||||
// Initialize WinRT apartment if not already initialized
|
||||
// Qt or other components may have already initialized it
|
||||
try {
|
||||
winrt::init_apartment(winrt::apartment_type::single_threaded);
|
||||
apartment_initialized_ = true;
|
||||
}
|
||||
catch (const hresult_error &e) {
|
||||
// Apartment already initialized - this is fine, continue
|
||||
if (e.code() != RPC_E_CHANGED_MODE) {
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// Create private implementation
|
||||
auto *priv = new WindowsMediaControllerPrivate();
|
||||
smtc_ = priv;
|
||||
|
||||
// Get the SystemMediaTransportControls instance for this window
|
||||
// Use the interop interface
|
||||
auto interop = winrt::get_activation_factory<SystemMediaTransportControls, ISystemMediaTransportControlsInterop>();
|
||||
|
||||
if (!interop) {
|
||||
qLog(Warning) << "Failed to get ISystemMediaTransportControlsInterop";
|
||||
delete priv;
|
||||
smtc_ = nullptr;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get SMTC for the window
|
||||
winrt::com_ptr<IInspectable> inspectable;
|
||||
HRESULT hr = interop->GetForWindow(hwnd, winrt::guid_of<SystemMediaTransportControls>(), inspectable.put_void());
|
||||
|
||||
if (FAILED(hr) || !inspectable) {
|
||||
qLog(Warning) << "Failed to get SystemMediaTransportControls for window, HRESULT:" << Qt::hex << static_cast<unsigned int>(hr);
|
||||
delete priv;
|
||||
smtc_ = nullptr;
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to SystemMediaTransportControls
|
||||
priv->smtc = inspectable.as<SystemMediaTransportControls>();
|
||||
|
||||
if (!priv->smtc) {
|
||||
qLog(Warning) << "Failed to cast to SystemMediaTransportControls";
|
||||
delete priv;
|
||||
smtc_ = nullptr;
|
||||
return;
|
||||
}
|
||||
|
||||
// Enable the controls
|
||||
priv->smtc.IsEnabled(true);
|
||||
priv->smtc.IsPlayEnabled(true);
|
||||
priv->smtc.IsPauseEnabled(true);
|
||||
priv->smtc.IsStopEnabled(true);
|
||||
priv->smtc.IsNextEnabled(true);
|
||||
priv->smtc.IsPreviousEnabled(true);
|
||||
|
||||
// Setup button handlers
|
||||
SetupButtonHandlers();
|
||||
|
||||
// Connect signals from Player
|
||||
QObject::connect(&*player_->engine(), &EngineBase::StateChanged, this, &WindowsMediaController::EngineStateChanged);
|
||||
QObject::connect(&*playlist_manager_, &PlaylistManager::CurrentSongChanged, this, &WindowsMediaController::CurrentSongChanged);
|
||||
QObject::connect(&*current_albumcover_loader_, &CurrentAlbumCoverLoader::AlbumCoverLoaded, this, &WindowsMediaController::AlbumCoverLoaded);
|
||||
|
||||
qLog(Info) << "Windows Media Transport Controls initialized successfully";
|
||||
}
|
||||
catch (const hresult_error &e) {
|
||||
qLog(Warning) << "Failed to initialize Windows Media Transport Controls:" << QString::fromWCharArray(e.message().c_str());
|
||||
if (smtc_) {
|
||||
delete static_cast<WindowsMediaControllerPrivate*>(smtc_);
|
||||
smtc_ = nullptr;
|
||||
}
|
||||
}
|
||||
catch (...) {
|
||||
qLog(Warning) << "Failed to initialize Windows Media Transport Controls: unknown error";
|
||||
if (smtc_) {
|
||||
delete static_cast<WindowsMediaControllerPrivate*>(smtc_);
|
||||
smtc_ = nullptr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WindowsMediaController::~WindowsMediaController() {
|
||||
if (smtc_) {
|
||||
auto *priv = static_cast<WindowsMediaControllerPrivate*>(smtc_);
|
||||
if (priv->smtc) {
|
||||
priv->smtc.IsEnabled(false);
|
||||
}
|
||||
delete priv;
|
||||
smtc_ = nullptr;
|
||||
}
|
||||
// Only uninit if we initialized the apartment
|
||||
if (apartment_initialized_) {
|
||||
winrt::uninit_apartment();
|
||||
}
|
||||
}
|
||||
|
||||
void WindowsMediaController::SetupButtonHandlers() {
|
||||
if (!smtc_) return;
|
||||
|
||||
auto *priv = static_cast<WindowsMediaControllerPrivate*>(smtc_);
|
||||
if (!priv->smtc) return;
|
||||
|
||||
// Handle button pressed events
|
||||
priv->smtc.ButtonPressed([this](const SystemMediaTransportControls &, const SystemMediaTransportControlsButtonPressedEventArgs &args) {
|
||||
switch (args.Button()) {
|
||||
case SystemMediaTransportControlsButton::Play:
|
||||
player_->Play();
|
||||
break;
|
||||
case SystemMediaTransportControlsButton::Pause:
|
||||
player_->Pause();
|
||||
break;
|
||||
case SystemMediaTransportControlsButton::Stop:
|
||||
player_->Stop();
|
||||
break;
|
||||
case SystemMediaTransportControlsButton::Next:
|
||||
player_->Next();
|
||||
break;
|
||||
case SystemMediaTransportControlsButton::Previous:
|
||||
player_->Previous();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void WindowsMediaController::EngineStateChanged(EngineBase::State newState) {
|
||||
UpdatePlaybackStatus(newState);
|
||||
}
|
||||
|
||||
void WindowsMediaController::UpdatePlaybackStatus(EngineBase::State state) {
|
||||
if (!smtc_) return;
|
||||
|
||||
auto *priv = static_cast<WindowsMediaControllerPrivate*>(smtc_);
|
||||
if (!priv->smtc) return;
|
||||
|
||||
try {
|
||||
switch (state) {
|
||||
case EngineBase::State::Playing:
|
||||
priv->smtc.PlaybackStatus(MediaPlaybackStatus::Playing);
|
||||
break;
|
||||
case EngineBase::State::Paused:
|
||||
priv->smtc.PlaybackStatus(MediaPlaybackStatus::Paused);
|
||||
break;
|
||||
case EngineBase::State::Empty:
|
||||
case EngineBase::State::Idle:
|
||||
priv->smtc.PlaybackStatus(MediaPlaybackStatus::Stopped);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (const hresult_error &e) {
|
||||
qLog(Warning) << "Failed to update playback status:" << QString::fromWCharArray(e.message().c_str());
|
||||
}
|
||||
}
|
||||
|
||||
void WindowsMediaController::CurrentSongChanged(const Song &song) {
|
||||
if (!song.is_valid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update metadata immediately with what we have
|
||||
UpdateMetadata(song, QUrl());
|
||||
|
||||
// Album cover will be updated via AlbumCoverLoaded signal
|
||||
}
|
||||
|
||||
void WindowsMediaController::AlbumCoverLoaded(const Song &song, const AlbumCoverLoaderResult &result) {
|
||||
if (!song.is_valid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update metadata with album cover
|
||||
UpdateMetadata(song, result.temp_cover_url.isEmpty() ? result.album_cover.cover_url : result.temp_cover_url);
|
||||
}
|
||||
|
||||
void WindowsMediaController::UpdateMetadata(const Song &song, const QUrl &art_url) {
|
||||
if (!smtc_) return;
|
||||
|
||||
auto *priv = static_cast<WindowsMediaControllerPrivate*>(smtc_);
|
||||
if (!priv->smtc) return;
|
||||
|
||||
try {
|
||||
// Get the updater
|
||||
SystemMediaTransportControlsDisplayUpdater updater = priv->smtc.DisplayUpdater();
|
||||
updater.Type(MediaPlaybackType::Music);
|
||||
|
||||
// Get the music properties
|
||||
auto musicProperties = updater.MusicProperties();
|
||||
|
||||
// Set basic metadata
|
||||
if (!song.title().isEmpty()) {
|
||||
musicProperties.Title(winrt::hstring(song.title().toStdWString()));
|
||||
}
|
||||
if (!song.artist().isEmpty()) {
|
||||
musicProperties.Artist(winrt::hstring(song.artist().toStdWString()));
|
||||
}
|
||||
if (!song.album().isEmpty()) {
|
||||
musicProperties.AlbumTitle(winrt::hstring(song.album().toStdWString()));
|
||||
}
|
||||
|
||||
// Set album art if available
|
||||
if (art_url.isValid() && art_url.isLocalFile()) {
|
||||
QString artPath = art_url.toLocalFile();
|
||||
if (!artPath.isEmpty()) {
|
||||
try {
|
||||
// Use file:// URI to avoid async blocking in STA thread
|
||||
QString fileUri = QUrl::fromLocalFile(artPath).toString();
|
||||
auto thumbnailStream = RandomAccessStreamReference::CreateFromUri(
|
||||
winrt::Windows::Foundation::Uri(winrt::hstring(fileUri.toStdWString()))
|
||||
);
|
||||
updater.Thumbnail(thumbnailStream);
|
||||
current_song_art_url_ = artPath;
|
||||
}
|
||||
catch (const hresult_error &e) {
|
||||
qLog(Debug) << "Failed to set album art:" << QString::fromWCharArray(e.message().c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the display
|
||||
updater.Update();
|
||||
}
|
||||
catch (const hresult_error &e) {
|
||||
qLog(Warning) << "Failed to update metadata:" << QString::fromWCharArray(e.message().c_str());
|
||||
}
|
||||
}
|
||||
69
src/core/windowsmediacontroller.h
Normal file
69
src/core/windowsmediacontroller.h
Normal file
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Strawberry Music Player
|
||||
* Copyright 2025, Jonas Kvinge <jonas@jkvinge.net>
|
||||
*
|
||||
* Strawberry is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Strawberry is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
#ifndef WINDOWSMEDIACONTROLLER_H
|
||||
#define WINDOWSMEDIACONTROLLER_H
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
#include "includes/shared_ptr.h"
|
||||
#include "engine/enginebase.h"
|
||||
#include "covermanager/albumcoverloaderresult.h"
|
||||
|
||||
class Player;
|
||||
class PlaylistManager;
|
||||
class CurrentAlbumCoverLoader;
|
||||
class Song;
|
||||
|
||||
class WindowsMediaController : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit WindowsMediaController(HWND hwnd,
|
||||
const SharedPtr<Player> player,
|
||||
const SharedPtr<PlaylistManager> playlist_manager,
|
||||
const SharedPtr<CurrentAlbumCoverLoader> current_albumcover_loader,
|
||||
QObject *parent = nullptr);
|
||||
~WindowsMediaController() override;
|
||||
|
||||
private Q_SLOTS:
|
||||
void AlbumCoverLoaded(const Song &song, const AlbumCoverLoaderResult &result = AlbumCoverLoaderResult());
|
||||
void EngineStateChanged(EngineBase::State newState);
|
||||
void CurrentSongChanged(const Song &song);
|
||||
|
||||
private:
|
||||
void UpdatePlaybackStatus(EngineBase::State state);
|
||||
void UpdateMetadata(const Song &song, const QUrl &art_url);
|
||||
void SetupButtonHandlers();
|
||||
|
||||
private:
|
||||
const SharedPtr<Player> player_;
|
||||
const SharedPtr<PlaylistManager> playlist_manager_;
|
||||
const SharedPtr<CurrentAlbumCoverLoader> current_albumcover_loader_;
|
||||
void *smtc_; // Pointer to SystemMediaTransportControls (opaque to avoid WinRT headers in public header)
|
||||
QString current_song_art_url_;
|
||||
bool apartment_initialized_; // Track if we initialized the WinRT apartment
|
||||
};
|
||||
|
||||
#endif // WINDOWSMEDIACONTROLLER_H
|
||||
@@ -81,6 +81,7 @@
|
||||
#include "utilities/coverutils.h"
|
||||
#include "utilities/coveroptions.h"
|
||||
#include "tagreader/tagreaderclient.h"
|
||||
#include "tagreader/tagid3v2version.h"
|
||||
#include "widgets/busyindicator.h"
|
||||
#include "widgets/lineedit.h"
|
||||
#include "collection/collectionbackend.h"
|
||||
@@ -104,14 +105,29 @@
|
||||
using std::make_shared;
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
#ifdef __clang__
|
||||
# pragma clang diagnostic push
|
||||
# pragma clang diagnostic ignored "-Wunused-const-variable"
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
constexpr char kSettingsGroup[] = "EditTagDialog";
|
||||
constexpr int kSmallImageSize = 128;
|
||||
|
||||
// ID3v2 version constants
|
||||
constexpr int kID3v2_Version_3 = 3;
|
||||
constexpr int kID3v2_Version_4 = 4;
|
||||
constexpr int kComboBoxIndex_ID3v2_3 = 0;
|
||||
constexpr int kComboBoxIndex_ID3v2_4 = 1;
|
||||
} // namespace
|
||||
|
||||
const char EditTagDialog::kTagsDifferentHintText[] = QT_TR_NOOP("(different across multiple songs)");
|
||||
const char EditTagDialog::kArtDifferentHintText[] = QT_TR_NOOP("Different art across multiple songs.");
|
||||
|
||||
#ifdef __clang_
|
||||
# pragma clang diagnostic pop
|
||||
#endif
|
||||
|
||||
EditTagDialog::EditTagDialog(const SharedPtr<NetworkAccessManager> network,
|
||||
const SharedPtr<TagReaderClient> tagreader_client,
|
||||
const SharedPtr<CollectionBackend> collection_backend,
|
||||
@@ -708,6 +724,9 @@ void EditTagDialog::SelectionChanged() {
|
||||
bool titlesort_enabled = false;
|
||||
bool artistsort_enabled = false;
|
||||
bool albumsort_enabled = false;
|
||||
bool has_id3v2_support = false;
|
||||
int id3v2_version = 0;
|
||||
bool id3v2_version_different = false;
|
||||
for (const QModelIndex &idx : indexes) {
|
||||
if (data_.value(idx.row()).cover_action_ == UpdateCoverAction::None) {
|
||||
data_[idx.row()].cover_result_ = AlbumCoverImageResult();
|
||||
@@ -769,6 +788,15 @@ void EditTagDialog::SelectionChanged() {
|
||||
if (song.albumsort_supported()) {
|
||||
albumsort_enabled = true;
|
||||
}
|
||||
if (song.id3v2_tags_supported()) {
|
||||
has_id3v2_support = true;
|
||||
if (id3v2_version == 0) {
|
||||
id3v2_version = song.id3v2_version();
|
||||
}
|
||||
else if (id3v2_version != song.id3v2_version()) {
|
||||
id3v2_version_different = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QString summary;
|
||||
@@ -840,6 +868,23 @@ void EditTagDialog::SelectionChanged() {
|
||||
ui_->artistsort->setEnabled(artistsort_enabled);
|
||||
ui_->albumsort->setEnabled(albumsort_enabled);
|
||||
|
||||
ui_->label_id3v2_version->setVisible(has_id3v2_support);
|
||||
ui_->combobox_id3v2_version->setVisible(has_id3v2_support);
|
||||
|
||||
if (has_id3v2_support) {
|
||||
// Set default based on existing version(s)
|
||||
if (id3v2_version_different || id3v2_version == 0) {
|
||||
// Mixed versions or unknown - default to ID3v2.4
|
||||
ui_->combobox_id3v2_version->setCurrentIndex(kComboBoxIndex_ID3v2_4);
|
||||
}
|
||||
else if (id3v2_version == kID3v2_Version_3) {
|
||||
ui_->combobox_id3v2_version->setCurrentIndex(kComboBoxIndex_ID3v2_3);
|
||||
}
|
||||
else {
|
||||
ui_->combobox_id3v2_version->setCurrentIndex(kComboBoxIndex_ID3v2_4);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void EditTagDialog::UpdateUI(const QModelIndexList &indexes) {
|
||||
@@ -1371,6 +1416,13 @@ void EditTagDialog::SaveData() {
|
||||
}
|
||||
save_tag_cover_data.cover_data = ref.cover_result_.image_data;
|
||||
}
|
||||
|
||||
// Determine ID3v2 version based on user selection
|
||||
TagID3v2Version tag_id3v2_version = TagID3v2Version::Default;
|
||||
if (ref.current_.filetype() == Song::FileType::MPEG || ref.current_.filetype() == Song::FileType::WAV || ref.current_.filetype() == Song::FileType::AIFF) {
|
||||
tag_id3v2_version = ui_->combobox_id3v2_version->currentIndex() == kComboBoxIndex_ID3v2_3 ? TagID3v2Version::V3 : TagID3v2Version::V4;
|
||||
}
|
||||
|
||||
TagReaderClient::SaveOptions save_tags_options;
|
||||
if (save_tags) {
|
||||
save_tags_options |= TagReaderClient::SaveOption::Tags;
|
||||
@@ -1384,7 +1436,7 @@ void EditTagDialog::SaveData() {
|
||||
if (save_embedded_cover) {
|
||||
save_tags_options |= TagReaderClient::SaveOption::Cover;
|
||||
}
|
||||
TagReaderReplyPtr reply = tagreader_client_->WriteFileAsync(ref.current_.url().toLocalFile(), ref.current_, save_tags_options, save_tag_cover_data);
|
||||
TagReaderReplyPtr reply = tagreader_client_->WriteFileAsync(ref.current_.url().toLocalFile(), ref.current_, save_tags_options, save_tag_cover_data, tag_id3v2_version);
|
||||
SharedPtr<QMetaObject::Connection> connection = make_shared<QMetaObject::Connection>();
|
||||
*connection = QObject::connect(&*reply, &TagReaderReply::Finished, this, [this, reply, ref, connection]() {
|
||||
SongSaveTagsComplete(reply, ref.current_.url().toLocalFile(), ref.current_, ref.cover_action_);
|
||||
|
||||
@@ -650,6 +650,47 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="layout_id3v2_version">
|
||||
<item>
|
||||
<widget class="QLabel" name="label_id3v2_version">
|
||||
<property name="text">
|
||||
<string>ID3v2 version:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QComboBox" name="combobox_id3v2_version">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>2.3</string>
|
||||
</property>
|
||||
</item>
|
||||
<item>
|
||||
<property name="text">
|
||||
<string>2.4</string>
|
||||
</property>
|
||||
</item>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="spacer_id3v2_version">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="sizeHint" stdset="0">
|
||||
<size>
|
||||
<width>0</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
</spacer>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="spacer_albumart_bottom">
|
||||
<property name="orientation">
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
#include "core/enginemetadata.h"
|
||||
#include "constants/timeconstants.h"
|
||||
#include "enginebase.h"
|
||||
#include "gsturl.h"
|
||||
#include "gstengine.h"
|
||||
#include "gstenginepipeline.h"
|
||||
#include "gstbufferconsumer.h"
|
||||
@@ -179,15 +180,18 @@ EngineBase::State GstEngine::state() const {
|
||||
|
||||
void GstEngine::StartPreloading(const QUrl &media_url, const QUrl &stream_url, const bool force_stop_at_end, const qint64 beginning_offset_nanosec, const qint64 end_offset_nanosec) {
|
||||
|
||||
const QByteArray gst_url = FixupUrl(stream_url);
|
||||
const GstUrl gst_url = FixupUrl(stream_url);
|
||||
|
||||
// No crossfading, so we can just queue the new URL in the existing pipeline and get gapless playback (hopefully)
|
||||
if (current_pipeline_) {
|
||||
current_pipeline_->PrepareNextUrl(media_url, stream_url, gst_url, beginning_offset_nanosec, force_stop_at_end ? end_offset_nanosec : 0);
|
||||
if (!gst_url.source_device.isEmpty()) {
|
||||
current_pipeline_->SetSourceDevice(gst_url.source_device);
|
||||
}
|
||||
current_pipeline_->PrepareNextUrl(media_url, stream_url, gst_url.url, beginning_offset_nanosec, force_stop_at_end ? end_offset_nanosec : 0);
|
||||
// Add request to discover the stream
|
||||
if (discoverer_ && media_url.scheme() != u"spotify"_s) {
|
||||
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) {
|
||||
qLog(Error) << "Failed to start stream discovery for" << gst_url;
|
||||
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.url.constData())) {
|
||||
qLog(Error) << "Failed to start stream discovery for" << gst_url.url;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,7 +202,7 @@ bool GstEngine::Load(const QUrl &media_url, const QUrl &stream_url, const Engine
|
||||
|
||||
EngineBase::Load(media_url, stream_url, change, force_stop_at_end, beginning_offset_nanosec, end_offset_nanosec, ebur128_integrated_loudness_lufs);
|
||||
|
||||
const QByteArray gst_url = FixupUrl(stream_url);
|
||||
const GstUrl gst_url = FixupUrl(stream_url);
|
||||
|
||||
bool crossfade = current_pipeline_ && ((crossfade_enabled_ && change & EngineBase::TrackChangeType::Manual) || (autocrossfade_enabled_ && change & EngineBase::TrackChangeType::Auto) || ((crossfade_enabled_ || autocrossfade_enabled_) && change & EngineBase::TrackChangeType::Intro));
|
||||
|
||||
@@ -215,9 +219,14 @@ bool GstEngine::Load(const QUrl &media_url, const QUrl &stream_url, const Engine
|
||||
}
|
||||
}
|
||||
|
||||
GstEnginePipelinePtr pipeline = CreatePipeline(media_url, stream_url, gst_url, static_cast<qint64>(beginning_offset_nanosec), force_stop_at_end ? end_offset_nanosec : 0, ebur128_loudness_normalizing_gain_db_);
|
||||
GstEnginePipelinePtr pipeline = CreatePipeline(media_url, stream_url, gst_url.url, static_cast<qint64>(beginning_offset_nanosec), force_stop_at_end ? end_offset_nanosec : 0, ebur128_loudness_normalizing_gain_db_);
|
||||
if (!pipeline) return false;
|
||||
|
||||
// Set the source device if one was extracted from the URL
|
||||
if (!gst_url.source_device.isEmpty()) {
|
||||
pipeline->SetSourceDevice(gst_url.source_device);
|
||||
}
|
||||
|
||||
GstEnginePipelinePtr old_pipeline = current_pipeline_;
|
||||
current_pipeline_ = pipeline;
|
||||
|
||||
@@ -253,8 +262,8 @@ bool GstEngine::Load(const QUrl &media_url, const QUrl &stream_url, const Engine
|
||||
|
||||
// Add request to discover the stream
|
||||
if (discoverer_ && media_url.scheme() != u"spotify"_s) {
|
||||
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.constData())) {
|
||||
qLog(Error) << "Failed to start stream discovery for" << gst_url;
|
||||
if (!gst_discoverer_discover_uri_async(discoverer_, gst_url.url.constData())) {
|
||||
qLog(Error) << "Failed to start stream discovery for" << gst_url.url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -814,16 +823,16 @@ void GstEngine::BufferingFinished() {
|
||||
|
||||
}
|
||||
|
||||
QByteArray GstEngine::FixupUrl(const QUrl &url) {
|
||||
GstUrl GstEngine::FixupUrl(const QUrl &url) {
|
||||
|
||||
QByteArray uri;
|
||||
GstUrl gst_url;
|
||||
|
||||
// It's a file:// url with a hostname set.
|
||||
// QUrl::fromLocalFile does this when given a \\host\share\file path on Windows.
|
||||
// Munge it back into a path that gstreamer will recognise.
|
||||
if (url.isLocalFile() && !url.host().isEmpty()) {
|
||||
QString str = "file:////"_L1 + url.host() + url.path();
|
||||
uri = str.toUtf8();
|
||||
gst_url.url = str.toUtf8();
|
||||
}
|
||||
else if (url.scheme() == "cdda"_L1) {
|
||||
QString str;
|
||||
@@ -837,16 +846,15 @@ QByteArray GstEngine::FixupUrl(const QUrl &url) {
|
||||
// We keep the device in mind, and we will set it later using SourceSetupCallback
|
||||
QStringList path = url.path().split(u'/');
|
||||
str = QStringLiteral("cdda://%1").arg(path.takeLast());
|
||||
QString device = path.join(u'/');
|
||||
if (current_pipeline_) current_pipeline_->SetSourceDevice(device);
|
||||
gst_url.source_device = path.join(u'/');
|
||||
}
|
||||
uri = str.toUtf8();
|
||||
gst_url.url = str.toUtf8();
|
||||
}
|
||||
else {
|
||||
uri = url.toEncoded();
|
||||
gst_url.url = url.toEncoded();
|
||||
}
|
||||
|
||||
return uri;
|
||||
return gst_url;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
|
||||
#include "includes/shared_ptr.h"
|
||||
#include "enginebase.h"
|
||||
#include "gsturl.h"
|
||||
#include "gstenginepipeline.h"
|
||||
#include "gstbufferconsumer.h"
|
||||
|
||||
@@ -123,7 +124,7 @@ class GstEngine : public EngineBase, public GstBufferConsumer {
|
||||
void PipelineFinished(const int pipeline_id);
|
||||
|
||||
private:
|
||||
QByteArray FixupUrl(const QUrl &url);
|
||||
GstUrl FixupUrl(const QUrl &url);
|
||||
|
||||
void StartFadeout(GstEnginePipelinePtr pipeline);
|
||||
void StartFadeoutPause();
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
#include <glib.h>
|
||||
#include <glib-object.h>
|
||||
@@ -42,6 +43,7 @@
|
||||
#include <QObject>
|
||||
#include <QCoreApplication>
|
||||
#include <QtConcurrentRun>
|
||||
#include <QThreadPool>
|
||||
#include <QFuture>
|
||||
#include <QFutureWatcher>
|
||||
#include <QMutex>
|
||||
@@ -90,6 +92,9 @@ constexpr std::chrono::milliseconds kFaderTimeoutMsec = 3000ms;
|
||||
constexpr int kEqBandCount = 10;
|
||||
constexpr int kEqBandFrequencies[] = { 60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000 };
|
||||
|
||||
// When within this many seconds of track end during gapless playback, ignore buffering messages
|
||||
constexpr int kIgnoreBufferingNearEndSeconds = 5;
|
||||
|
||||
} // namespace
|
||||
|
||||
#ifdef __clang_
|
||||
@@ -98,6 +103,23 @@ constexpr int kEqBandFrequencies[] = { 60, 170, 310, 600, 1000, 3000, 6000, 1200
|
||||
|
||||
int GstEnginePipeline::sId = 1;
|
||||
|
||||
QThreadPool *GstEnginePipeline::shared_state_threadpool() {
|
||||
|
||||
// C++11 guarantees thread-safe initialization of static local variables
|
||||
static QThreadPool pool;
|
||||
static const auto init = []() {
|
||||
// Limit the number of threads to prevent resource exhaustion
|
||||
// Use 2 threads max since state changes are typically sequential per pipeline
|
||||
pool.setMaxThreadCount(2);
|
||||
return true;
|
||||
}();
|
||||
|
||||
Q_UNUSED(init);
|
||||
|
||||
return &pool;
|
||||
|
||||
}
|
||||
|
||||
GstEnginePipeline::GstEnginePipeline(QObject *parent)
|
||||
: QObject(parent),
|
||||
id_(sId++),
|
||||
@@ -197,6 +219,23 @@ GstEnginePipeline::~GstEnginePipeline() {
|
||||
|
||||
if (pipeline_) {
|
||||
|
||||
// Wait for any ongoing state changes for this pipeline to complete before setting to NULL.
|
||||
// This prevents race conditions with async state transitions.
|
||||
{
|
||||
// Copy futures to local list to avoid holding mutex during waitForFinished()
|
||||
QList<QFuture<GstStateChangeReturn>> futures_to_wait;
|
||||
{
|
||||
QMutexLocker locker(&mutex_pending_state_changes_);
|
||||
futures_to_wait = pending_state_changes_;
|
||||
pending_state_changes_.clear();
|
||||
}
|
||||
|
||||
// Wait for all pending futures to complete
|
||||
for (QFuture<GstStateChangeReturn> &future : futures_to_wait) {
|
||||
future.waitForFinished();
|
||||
}
|
||||
}
|
||||
|
||||
gst_element_set_state(pipeline_, GST_STATE_NULL);
|
||||
|
||||
GstElement *audiobin = nullptr;
|
||||
@@ -1364,6 +1403,13 @@ void GstEnginePipeline::AboutToFinishCallback(GstPlayBin *playbin, gpointer self
|
||||
|
||||
GstEnginePipeline *instance = reinterpret_cast<GstEnginePipeline*>(self);
|
||||
|
||||
// Ignore about-to-finish if we're in the process of tearing down the pipeline
|
||||
// This prevents race conditions in GStreamer's decodebin3 when rapidly switching tracks
|
||||
// See: https://gitlab.freedesktop.org/gstreamer/gstreamer/-/issues/4626
|
||||
if (instance->finish_requested_.value()) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
QMutexLocker l(&instance->mutex_url_);
|
||||
qLog(Debug) << "Stream from URL" << instance->gst_url_ << "about to finish.";
|
||||
@@ -1740,6 +1786,18 @@ void GstEnginePipeline::BufferingMessageReceived(GstMessage *msg) {
|
||||
const GstState current_state = state();
|
||||
|
||||
if (percent < 100 && !buffering_.value()) {
|
||||
// If we're near the end of the track and about-to-finish has been signaled, ignore buffering messages to prevent getting stuck in buffering state.
|
||||
// This can happen with local files where spurious buffering messages appear near the end while the next track is being prepared for gapless playback.
|
||||
if (about_to_finish_.value()) {
|
||||
const qint64 current_position = position();
|
||||
const qint64 track_length = length();
|
||||
// Ignore buffering if we're within kIgnoreBufferingNearEndSeconds of the end
|
||||
if (track_length > 0 && current_position > 0 && (track_length - current_position) < kIgnoreBufferingNearEndSeconds * kNsecPerSec) {
|
||||
qLog(Debug) << "Ignoring buffering message near end of track (position:" << current_position << "length:" << track_length << ")";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
qLog(Debug) << "Buffering started";
|
||||
buffering_ = true;
|
||||
Q_EMIT BufferingStarted();
|
||||
@@ -1841,9 +1899,15 @@ QFuture<GstStateChangeReturn> GstEnginePipeline::SetState(const GstState state)
|
||||
watcher->deleteLater();
|
||||
SetStateFinishedSlot(state, state_change_return);
|
||||
});
|
||||
QFuture<GstStateChangeReturn> future = QtConcurrent::run(&set_state_threadpool_, &gst_element_set_state, pipeline_, state);
|
||||
QFuture<GstStateChangeReturn> future = QtConcurrent::run(shared_state_threadpool(), &gst_element_set_state, pipeline_, state);
|
||||
watcher->setFuture(future);
|
||||
|
||||
// Track this future so destructor can wait for it
|
||||
{
|
||||
QMutexLocker locker(&mutex_pending_state_changes_);
|
||||
pending_state_changes_.append(future);
|
||||
}
|
||||
|
||||
return future;
|
||||
|
||||
}
|
||||
@@ -1853,6 +1917,12 @@ void GstEnginePipeline::SetStateFinishedSlot(const GstState state, const GstStat
|
||||
last_set_state_in_progress_ = GST_STATE_VOID_PENDING;
|
||||
--set_state_in_progress_;
|
||||
|
||||
// Remove finished futures from tracking list to prevent unbounded growth
|
||||
{
|
||||
QMutexLocker locker(&mutex_pending_state_changes_);
|
||||
pending_state_changes_.erase(std::remove_if(pending_state_changes_.begin(), pending_state_changes_.end(), [](const QFuture<GstStateChangeReturn> &f) { return f.isFinished(); }), pending_state_changes_.end());
|
||||
}
|
||||
|
||||
switch (state_change_return) {
|
||||
case GST_STATE_CHANGE_SUCCESS:
|
||||
case GST_STATE_CHANGE_ASYNC:
|
||||
|
||||
@@ -215,7 +215,8 @@ class GstEnginePipeline : public QObject {
|
||||
static int sId;
|
||||
mutex_protected<int> id_;
|
||||
|
||||
QThreadPool set_state_threadpool_;
|
||||
// Shared thread pool for all pipeline state changes to prevent thread/FD exhaustion
|
||||
static QThreadPool *shared_state_threadpool();
|
||||
|
||||
bool playbin3_support_;
|
||||
bool volume_full_range_support_;
|
||||
@@ -384,6 +385,10 @@ class GstEnginePipeline : public QObject {
|
||||
|
||||
mutex_protected<GstState> last_set_state_in_progress_;
|
||||
mutex_protected<GstState> last_set_state_async_in_progress_;
|
||||
|
||||
// Track futures for this pipeline's state changes to allow waiting for them in destructor
|
||||
QList<QFuture<GstStateChangeReturn>> pending_state_changes_;
|
||||
QMutex mutex_pending_state_changes_;
|
||||
};
|
||||
|
||||
using GstEnginePipelinePtr = QSharedPointer<GstEnginePipeline>;
|
||||
|
||||
32
src/engine/gsturl.h
Normal file
32
src/engine/gsturl.h
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Strawberry Music Player
|
||||
* Copyright 2025, Jonas Kvinge <jonas@jkvinge.net>
|
||||
*
|
||||
* Strawberry is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Strawberry is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
#ifndef GSTURL_H
|
||||
#define GSTURL_H
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
|
||||
class GstUrl {
|
||||
public:
|
||||
QByteArray url;
|
||||
QString source_device;
|
||||
};
|
||||
|
||||
#endif // GSTURL_H
|
||||
@@ -32,6 +32,7 @@
|
||||
#include <QKeySequence>
|
||||
|
||||
#include "core/logging.h"
|
||||
#include "core/settings.h"
|
||||
|
||||
#include "globalshortcutsmanager.h"
|
||||
#include "globalshortcutsbackend.h"
|
||||
@@ -58,29 +59,32 @@ using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
GlobalShortcutsManager::GlobalShortcutsManager(QWidget *parent) : QWidget(parent) {
|
||||
|
||||
settings_.beginGroup(GlobalShortcutsSettings::kSettingsGroup);
|
||||
Settings s;
|
||||
s.beginGroup(GlobalShortcutsSettings::kSettingsGroup);
|
||||
|
||||
// Create actions
|
||||
AddShortcut(u"play"_s, tr("Play"), std::bind(&GlobalShortcutsManager::Play, this));
|
||||
AddShortcut(u"pause"_s, tr("Pause"), std::bind(&GlobalShortcutsManager::Pause, this));
|
||||
AddShortcut(u"play_pause"_s, tr("Play/Pause"), std::bind(&GlobalShortcutsManager::PlayPause, this), QKeySequence(Qt::Key_MediaPlay));
|
||||
AddShortcut(u"stop"_s, tr("Stop"), std::bind(&GlobalShortcutsManager::Stop, this), QKeySequence(Qt::Key_MediaStop));
|
||||
AddShortcut(u"stop_after"_s, tr("Stop playing after current track"), std::bind(&GlobalShortcutsManager::StopAfter, this));
|
||||
AddShortcut(u"next_track"_s, tr("Next track"), std::bind(&GlobalShortcutsManager::Next, this), QKeySequence(Qt::Key_MediaNext));
|
||||
AddShortcut(u"prev_track"_s, tr("Previous track"), std::bind(&GlobalShortcutsManager::Previous, this), QKeySequence(Qt::Key_MediaPrevious));
|
||||
AddShortcut(u"restart_or_prev_track"_s, tr("Restart or previous track"), std::bind(&GlobalShortcutsManager::RestartOrPrevious, this));
|
||||
AddShortcut(u"inc_volume"_s, tr("Increase volume"), std::bind(&GlobalShortcutsManager::IncVolume, this));
|
||||
AddShortcut(u"dec_volume"_s, tr("Decrease volume"), std::bind(&GlobalShortcutsManager::DecVolume, this));
|
||||
AddShortcut(u"mute"_s, tr("Mute"), std::bind(&GlobalShortcutsManager::Mute, this));
|
||||
AddShortcut(u"seek_forward"_s, tr("Seek forward"), std::bind(&GlobalShortcutsManager::SeekForward, this));
|
||||
AddShortcut(u"seek_backward"_s, tr("Seek backward"), std::bind(&GlobalShortcutsManager::SeekBackward, this));
|
||||
AddShortcut(u"show_hide"_s, tr("Show/Hide"), std::bind(&GlobalShortcutsManager::ShowHide, this));
|
||||
AddShortcut(u"show_osd"_s, tr("Show OSD"), std::bind(&GlobalShortcutsManager::ShowOSD, this));
|
||||
AddShortcut(u"toggle_pretty_osd"_s, tr("Toggle Pretty OSD"), std::bind(&GlobalShortcutsManager::TogglePrettyOSD, this)); // Toggling possible only for pretty OSD
|
||||
AddShortcut(u"shuffle_mode"_s, tr("Change shuffle mode"), std::bind(&GlobalShortcutsManager::CycleShuffleMode, this));
|
||||
AddShortcut(u"repeat_mode"_s, tr("Change repeat mode"), std::bind(&GlobalShortcutsManager::CycleRepeatMode, this));
|
||||
AddShortcut(u"toggle_scrobbling"_s, tr("Enable/disable scrobbling"), std::bind(&GlobalShortcutsManager::ToggleScrobbling, this));
|
||||
AddShortcut(u"love"_s, tr("Love"), std::bind(&GlobalShortcutsManager::Love, this));
|
||||
AddShortcut(s, u"play"_s, tr("Play"), std::bind(&GlobalShortcutsManager::Play, this));
|
||||
AddShortcut(s, u"pause"_s, tr("Pause"), std::bind(&GlobalShortcutsManager::Pause, this));
|
||||
AddShortcut(s, u"play_pause"_s, tr("Play/Pause"), std::bind(&GlobalShortcutsManager::PlayPause, this), QKeySequence(Qt::Key_MediaPlay));
|
||||
AddShortcut(s, u"stop"_s, tr("Stop"), std::bind(&GlobalShortcutsManager::Stop, this), QKeySequence(Qt::Key_MediaStop));
|
||||
AddShortcut(s, u"stop_after"_s, tr("Stop playing after current track"), std::bind(&GlobalShortcutsManager::StopAfter, this));
|
||||
AddShortcut(s, u"next_track"_s, tr("Next track"), std::bind(&GlobalShortcutsManager::Next, this), QKeySequence(Qt::Key_MediaNext));
|
||||
AddShortcut(s, u"prev_track"_s, tr("Previous track"), std::bind(&GlobalShortcutsManager::Previous, this), QKeySequence(Qt::Key_MediaPrevious));
|
||||
AddShortcut(s, u"restart_or_prev_track"_s, tr("Restart or previous track"), std::bind(&GlobalShortcutsManager::RestartOrPrevious, this));
|
||||
AddShortcut(s, u"inc_volume"_s, tr("Increase volume"), std::bind(&GlobalShortcutsManager::IncVolume, this));
|
||||
AddShortcut(s, u"dec_volume"_s, tr("Decrease volume"), std::bind(&GlobalShortcutsManager::DecVolume, this));
|
||||
AddShortcut(s, u"mute"_s, tr("Mute"), std::bind(&GlobalShortcutsManager::Mute, this));
|
||||
AddShortcut(s, u"seek_forward"_s, tr("Seek forward"), std::bind(&GlobalShortcutsManager::SeekForward, this));
|
||||
AddShortcut(s, u"seek_backward"_s, tr("Seek backward"), std::bind(&GlobalShortcutsManager::SeekBackward, this));
|
||||
AddShortcut(s, u"show_hide"_s, tr("Show/Hide"), std::bind(&GlobalShortcutsManager::ShowHide, this));
|
||||
AddShortcut(s, u"show_osd"_s, tr("Show OSD"), std::bind(&GlobalShortcutsManager::ShowOSD, this));
|
||||
AddShortcut(s, u"toggle_pretty_osd"_s, tr("Toggle Pretty OSD"), std::bind(&GlobalShortcutsManager::TogglePrettyOSD, this)); // Toggling possible only for pretty OSD
|
||||
AddShortcut(s, u"shuffle_mode"_s, tr("Change shuffle mode"), std::bind(&GlobalShortcutsManager::CycleShuffleMode, this));
|
||||
AddShortcut(s, u"repeat_mode"_s, tr("Change repeat mode"), std::bind(&GlobalShortcutsManager::CycleRepeatMode, this));
|
||||
AddShortcut(s, u"toggle_scrobbling"_s, tr("Enable/disable scrobbling"), std::bind(&GlobalShortcutsManager::ToggleScrobbling, this));
|
||||
AddShortcut(s, u"love"_s, tr("Love"), std::bind(&GlobalShortcutsManager::Love, this));
|
||||
|
||||
s.endGroup();
|
||||
|
||||
// Create backends - these do the actual shortcut registration
|
||||
|
||||
@@ -116,35 +120,39 @@ void GlobalShortcutsManager::ReloadSettings() {
|
||||
backends_enabled_ << GlobalShortcutsBackend::Type::Win;
|
||||
#endif
|
||||
|
||||
{
|
||||
Settings s;
|
||||
s.beginGroup(GlobalShortcutsSettings::kSettingsGroup);
|
||||
#ifdef HAVE_KGLOBALACCEL_GLOBALSHORTCUTS
|
||||
if (settings_.value(GlobalShortcutsSettings::kUseKGlobalAccel, true).toBool()) {
|
||||
backends_enabled_ << GlobalShortcutsBackend::Type::KGlobalAccel;
|
||||
}
|
||||
if (s.value(GlobalShortcutsSettings::kUseKGlobalAccel, true).toBool()) {
|
||||
backends_enabled_ << GlobalShortcutsBackend::Type::KGlobalAccel;
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef HAVE_X11_GLOBALSHORTCUTS
|
||||
if (settings_.value(GlobalShortcutsSettings::kUseX11, false).toBool()) {
|
||||
backends_enabled_ << GlobalShortcutsBackend::Type::X11;
|
||||
}
|
||||
if (s.value(GlobalShortcutsSettings::kUseX11, false).toBool()) {
|
||||
backends_enabled_ << GlobalShortcutsBackend::Type::X11;
|
||||
}
|
||||
#endif
|
||||
s.endGroup();
|
||||
}
|
||||
|
||||
Unregister();
|
||||
Register();
|
||||
|
||||
}
|
||||
|
||||
void GlobalShortcutsManager::AddShortcut(const QString &id, const QString &name, std::function<void()> signal, const QKeySequence &default_key) { // clazy:exclude=function-args-by-ref
|
||||
void GlobalShortcutsManager::AddShortcut(Settings &s, const QString &id, const QString &name, std::function<void()> signal, const QKeySequence &default_key) { // clazy:exclude=function-args-by-ref
|
||||
|
||||
Shortcut shortcut = AddShortcut(id, name, default_key);
|
||||
Shortcut shortcut = AddShortcut(s, id, name, default_key);
|
||||
QObject::connect(shortcut.action, &QAction::triggered, this, signal);
|
||||
|
||||
}
|
||||
|
||||
GlobalShortcutsManager::Shortcut GlobalShortcutsManager::AddShortcut(const QString &id, const QString &name, const QKeySequence &default_key) {
|
||||
GlobalShortcutsManager::Shortcut GlobalShortcutsManager::AddShortcut(Settings &s, const QString &id, const QString &name, const QKeySequence &default_key) {
|
||||
|
||||
Shortcut shortcut;
|
||||
shortcut.action = new QAction(name, this);
|
||||
QKeySequence key_sequence = QKeySequence::fromString(settings_.value(id, default_key.toString()).toString());
|
||||
QKeySequence key_sequence = QKeySequence::fromString(s.value(id, default_key.toString()).toString());
|
||||
shortcut.action->setShortcut(key_sequence);
|
||||
shortcut.id = id;
|
||||
shortcut.default_key = default_key;
|
||||
|
||||
@@ -32,15 +32,14 @@
|
||||
#include <QMap>
|
||||
#include <QString>
|
||||
#include <QKeySequence>
|
||||
#include <QSettings>
|
||||
|
||||
#include "globalshortcutsbackend.h"
|
||||
|
||||
#include "core/settings.h"
|
||||
|
||||
class QShortcut;
|
||||
class QAction;
|
||||
|
||||
class Settings;
|
||||
|
||||
class GlobalShortcutsManager : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
@@ -99,12 +98,11 @@ class GlobalShortcutsManager : public QWidget {
|
||||
void Love();
|
||||
|
||||
private:
|
||||
void AddShortcut(const QString &id, const QString &name, std::function<void()> signal, const QKeySequence &default_key = QKeySequence(0));
|
||||
Shortcut AddShortcut(const QString &id, const QString &name, const QKeySequence &default_key);
|
||||
void AddShortcut(Settings &s, const QString &id, const QString &name, std::function<void()> signal, const QKeySequence &default_key = QKeySequence(0));
|
||||
Shortcut AddShortcut(Settings &s, const QString &id, const QString &name, const QKeySequence &default_key);
|
||||
|
||||
private:
|
||||
QList<GlobalShortcutsBackend*> backends_;
|
||||
Settings settings_;
|
||||
QList<GlobalShortcutsBackend::Type> backends_enabled_;
|
||||
QMap<QString, Shortcut> shortcuts_;
|
||||
};
|
||||
|
||||
@@ -93,6 +93,10 @@
|
||||
# include "discord/richpresence.h"
|
||||
#endif
|
||||
|
||||
#ifdef HAVE_WINDOWS_MEDIA_CONTROLS
|
||||
# include "core/windowsmediacontroller.h"
|
||||
#endif
|
||||
|
||||
#include "core/iconloader.h"
|
||||
#include "core/commandlineoptions.h"
|
||||
#include "core/networkproxyfactory.h"
|
||||
@@ -365,6 +369,11 @@ int main(int argc, char *argv[]) {
|
||||
#endif
|
||||
options);
|
||||
|
||||
#ifdef HAVE_WINDOWS_MEDIA_CONTROLS
|
||||
// Initialize Windows Media Transport Controls
|
||||
WindowsMediaController windows_media_controller(reinterpret_cast<HWND>(w.winId()), app.player(), app.playlist_manager(), app.current_albumcover_loader());
|
||||
#endif
|
||||
|
||||
#ifdef Q_OS_MACOS
|
||||
mac::EnableFullScreen(w);
|
||||
#endif // Q_OS_MACOS
|
||||
|
||||
@@ -1205,7 +1205,7 @@ void Playlist::InsertItemsWithoutUndo(const PlaylistItemPtrList &items, const in
|
||||
queue_->InsertFirst(indexes);
|
||||
}
|
||||
|
||||
if (auto_sort_) {
|
||||
if (auto_sort_ && !is_loading_) {
|
||||
sort(static_cast<int>(sort_column_), sort_order_);
|
||||
}
|
||||
|
||||
|
||||
@@ -106,8 +106,6 @@ PlaylistContainer::PlaylistContainer(QWidget *parent)
|
||||
no_matches_font.setBold(true);
|
||||
no_matches_label_->setFont(no_matches_font);
|
||||
|
||||
settings_.beginGroup(kSettingsGroup);
|
||||
|
||||
// Tab bar
|
||||
ui_->tab_bar->setExpanding(false);
|
||||
ui_->tab_bar->setMovable(true);
|
||||
@@ -257,7 +255,11 @@ void PlaylistContainer::ReloadSettings() {
|
||||
ui_->redo->setIconSize(QSize(iconsize, iconsize));
|
||||
ui_->search_field->setIconSize(iconsize);
|
||||
|
||||
bool playlist_clear = settings_.value("playlist_clear", true).toBool();
|
||||
s.beginGroup(kSettingsGroup);
|
||||
const bool playlist_clear = s.value("playlist_clear", true).toBool();
|
||||
const bool show_toolbar = s.value("show_toolbar", true).toBool();
|
||||
s.endGroup();
|
||||
|
||||
if (playlist_clear) {
|
||||
ui_->clear->show();
|
||||
}
|
||||
@@ -265,7 +267,6 @@ void PlaylistContainer::ReloadSettings() {
|
||||
ui_->clear->hide();
|
||||
}
|
||||
|
||||
bool show_toolbar = settings_.value("show_toolbar", true).toBool();
|
||||
ui_->toolbar->setVisible(show_toolbar);
|
||||
|
||||
if (!show_toolbar) ui_->search_field->clear();
|
||||
@@ -308,7 +309,12 @@ void PlaylistContainer::PlaylistAdded(const int id, const QString &name, const b
|
||||
ui_->tab_bar->InsertTab(id, index, name, favorite);
|
||||
|
||||
// Are we start up, should we select this tab?
|
||||
if (starting_up_ && settings_.value("current_playlist", 1).toInt() == id) {
|
||||
Settings s;
|
||||
s.beginGroup(kSettingsGroup);
|
||||
const int current_playlist = s.value("current_playlist", 1).toInt();
|
||||
s.endGroup();
|
||||
|
||||
if (starting_up_ && current_playlist == id) {
|
||||
starting_up_ = false;
|
||||
ui_->tab_bar->set_current_id(id);
|
||||
}
|
||||
@@ -347,12 +353,14 @@ void PlaylistContainer::NewPlaylist() { manager_->New(tr("Playlist")); }
|
||||
|
||||
void PlaylistContainer::LoadPlaylist() {
|
||||
|
||||
QString filename = settings_.value("last_load_playlist").toString();
|
||||
Settings s;
|
||||
s.beginGroup(kSettingsGroup);
|
||||
QString filename = s.value("last_load_playlist").toString();
|
||||
filename = QFileDialog::getOpenFileName(this, tr("Load playlist"), filename, manager_->parser()->filters(PlaylistParser::Type::Load));
|
||||
|
||||
if (filename.isNull()) return;
|
||||
|
||||
settings_.setValue("last_load_playlist", filename);
|
||||
s.setValue("last_load_playlist", filename);
|
||||
|
||||
manager_->Load(filename);
|
||||
|
||||
@@ -391,7 +399,10 @@ void PlaylistContainer::Save() {
|
||||
|
||||
if (starting_up_) return;
|
||||
|
||||
settings_.setValue("current_playlist", ui_->tab_bar->current_id());
|
||||
Settings s;
|
||||
s.beginGroup(kSettingsGroup);
|
||||
s.setValue("current_playlist", ui_->tab_bar->current_id());
|
||||
s.endGroup();
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
#include <QWidget>
|
||||
#include <QString>
|
||||
#include <QIcon>
|
||||
#include <QSettings>
|
||||
|
||||
class QTimer;
|
||||
class QTimeLine;
|
||||
@@ -45,7 +44,6 @@ class PlaylistView;
|
||||
class Ui_PlaylistContainer;
|
||||
|
||||
#include "includes/shared_ptr.h"
|
||||
#include "core/settings.h"
|
||||
|
||||
class PlaylistContainer : public QWidget {
|
||||
Q_OBJECT
|
||||
@@ -118,7 +116,6 @@ class PlaylistContainer : public QWidget {
|
||||
QAction *redo_;
|
||||
Playlist *playlist_;
|
||||
|
||||
Settings settings_;
|
||||
bool starting_up_;
|
||||
|
||||
bool tab_bar_visible_;
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
#include <QLinearGradient>
|
||||
#include <QScrollBar>
|
||||
#include <QtEvents>
|
||||
#include <QSettings>
|
||||
#include <QDrag>
|
||||
|
||||
#include "includes/qt_blurimage.h"
|
||||
|
||||
277
src/qobuz/qobuzcredentialfetcher.cpp
Normal file
277
src/qobuz/qobuzcredentialfetcher.cpp
Normal file
@@ -0,0 +1,277 @@
|
||||
/*
|
||||
* Strawberry Music Player
|
||||
* Copyright 2019-2025, Jonas Kvinge <jonas@jkvinge.net>
|
||||
*
|
||||
* Strawberry is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Strawberry is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QMap>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
#include <QNetworkRequest>
|
||||
#include <QNetworkReply>
|
||||
#include <QRegularExpression>
|
||||
#include <QRegularExpressionMatch>
|
||||
#include <QRegularExpressionMatchIterator>
|
||||
|
||||
#include "includes/shared_ptr.h"
|
||||
#include "core/logging.h"
|
||||
#include "core/networkaccessmanager.h"
|
||||
#include "qobuzcredentialfetcher.h"
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
namespace {
|
||||
constexpr char kLoginPageUrl[] = "https://play.qobuz.com/login";
|
||||
constexpr char kPlayQobuzUrl[] = "https://play.qobuz.com";
|
||||
} // namespace
|
||||
|
||||
QobuzCredentialFetcher::QobuzCredentialFetcher(const SharedPtr<NetworkAccessManager> network, QObject *parent)
|
||||
: QObject(parent),
|
||||
network_(network),
|
||||
login_page_reply_(nullptr),
|
||||
bundle_reply_(nullptr) {}
|
||||
|
||||
void QobuzCredentialFetcher::FetchCredentials() {
|
||||
|
||||
qLog(Debug) << "Qobuz: Fetching credentials from web player";
|
||||
|
||||
QNetworkRequest request(QUrl(QString::fromLatin1(kLoginPageUrl)));
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setHeader(QNetworkRequest::UserAgentHeader, u"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"_s);
|
||||
|
||||
login_page_reply_ = network_->get(request);
|
||||
QObject::connect(login_page_reply_, &QNetworkReply::finished, this, &QobuzCredentialFetcher::LoginPageReceived);
|
||||
|
||||
}
|
||||
|
||||
void QobuzCredentialFetcher::LoginPageReceived() {
|
||||
|
||||
if (!login_page_reply_) return;
|
||||
|
||||
QNetworkReply *reply = login_page_reply_;
|
||||
login_page_reply_ = nullptr;
|
||||
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
QString error = QStringLiteral("Failed to fetch login page: %1").arg(reply->errorString());
|
||||
qLog(Error) << "Qobuz:" << error;
|
||||
reply->deleteLater();
|
||||
Q_EMIT CredentialsFetchError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const QString login_page = QString::fromUtf8(reply->readAll());
|
||||
reply->deleteLater();
|
||||
|
||||
// Extract bundle.js URL from the login page
|
||||
// Pattern: <script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>
|
||||
static const QRegularExpression bundle_url_regex(u"<script src=\"(/resources/[\\d.]+-[a-z]\\d+/bundle\\.js)\"></script>"_s);
|
||||
const QRegularExpressionMatch bundle_match = bundle_url_regex.match(login_page);
|
||||
|
||||
if (!bundle_match.hasMatch()) {
|
||||
QString error = u"Failed to find bundle.js URL in login page"_s;
|
||||
qLog(Error) << "Qobuz:" << error;
|
||||
Q_EMIT CredentialsFetchError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
bundle_url_ = bundle_match.captured(1);
|
||||
qLog(Debug) << "Qobuz: Found bundle URL:" << bundle_url_;
|
||||
|
||||
// Fetch the bundle.js
|
||||
QNetworkRequest request(QUrl(QString::fromLatin1(kPlayQobuzUrl) + bundle_url_));
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
request.setHeader(QNetworkRequest::UserAgentHeader, u"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"_s);
|
||||
|
||||
bundle_reply_ = network_->get(request);
|
||||
QObject::connect(bundle_reply_, &QNetworkReply::finished, this, &QobuzCredentialFetcher::BundleReceived);
|
||||
|
||||
}
|
||||
|
||||
void QobuzCredentialFetcher::BundleReceived() {
|
||||
|
||||
if (!bundle_reply_) return;
|
||||
|
||||
QNetworkReply *reply = bundle_reply_;
|
||||
bundle_reply_ = nullptr;
|
||||
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
QString error = QStringLiteral("Failed to fetch bundle.js: %1").arg(reply->errorString());
|
||||
qLog(Error) << "Qobuz:" << error;
|
||||
reply->deleteLater();
|
||||
Q_EMIT CredentialsFetchError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const QString bundle = QString::fromUtf8(reply->readAll());
|
||||
reply->deleteLater();
|
||||
|
||||
qLog(Debug) << "Qobuz: Bundle size:" << bundle.length();
|
||||
|
||||
const QString app_id = ExtractAppId(bundle);
|
||||
if (app_id.isEmpty()) {
|
||||
QString error = u"Failed to extract app_id from bundle"_s;
|
||||
qLog(Error) << "Qobuz:" << error;
|
||||
Q_EMIT CredentialsFetchError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const QString app_secret = ExtractAppSecret(bundle);
|
||||
if (app_secret.isEmpty()) {
|
||||
QString error = u"Failed to extract app_secret from bundle"_s;
|
||||
qLog(Error) << "Qobuz:" << error;
|
||||
Q_EMIT CredentialsFetchError(error);
|
||||
return;
|
||||
}
|
||||
|
||||
qLog(Debug) << "Qobuz: Successfully extracted credentials - app_id:" << app_id;
|
||||
Q_EMIT CredentialsFetched(app_id, app_secret);
|
||||
|
||||
}
|
||||
|
||||
QString QobuzCredentialFetcher::ExtractAppId(const QString &bundle) {
|
||||
|
||||
// Pattern: production:{api:{appId:"(\d+)"
|
||||
static const QRegularExpression app_id_regex(u"production:\\{api:\\{appId:\"(\\d+)\""_s);
|
||||
const QRegularExpressionMatch app_id_match = app_id_regex.match(bundle);
|
||||
|
||||
if (app_id_match.hasMatch()) {
|
||||
return app_id_match.captured(1);
|
||||
}
|
||||
|
||||
return QString();
|
||||
|
||||
}
|
||||
|
||||
QString QobuzCredentialFetcher::ExtractAppSecret(const QString &bundle) {
|
||||
|
||||
// The plain-text appSecret in the bundle doesn't work for API requests.
|
||||
// We need to use the Spoofbuz method to extract the real secrets:
|
||||
// 1. Find seed/timezone pairs
|
||||
// 2. Find info/extras for each timezone
|
||||
// 3. Combine seed + info + extras, remove last 44 chars, base64 decode
|
||||
|
||||
// Pattern to find seed and timezone: [a-z].initialSeed("seed",window.utimezone.timezone)
|
||||
static const QRegularExpression seed_regex(u"[a-z]\\.initialSeed\\(\"([\\w=]+)\",window\\.utimezone\\.([a-z]+)\\)"_s);
|
||||
|
||||
QMap<QString, QString> seeds; // timezone -> seed
|
||||
QRegularExpressionMatchIterator seed_iter = seed_regex.globalMatch(bundle);
|
||||
while (seed_iter.hasNext()) {
|
||||
const QRegularExpressionMatch seed_match = seed_iter.next();
|
||||
const QString seed = seed_match.captured(1);
|
||||
const QString tz = seed_match.captured(2);
|
||||
seeds[tz] = seed;
|
||||
qLog(Debug) << "Qobuz: Found seed for timezone" << tz;
|
||||
}
|
||||
|
||||
if (seeds.isEmpty()) {
|
||||
qLog(Error) << "Qobuz: No seed/timezone pairs found in bundle";
|
||||
return QString();
|
||||
}
|
||||
|
||||
// Try each timezone - Berlin was confirmed working
|
||||
const QStringList preferred_order = {u"berlin"_s, u"london"_s, u"abidjan"_s};
|
||||
|
||||
for (const QString &tz : preferred_order) {
|
||||
if (!seeds.contains(tz)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pattern to find info and extras for this timezone
|
||||
// name:"xxx/Berlin",info:"...",extras:"..."
|
||||
const QString capitalized_tz = tz.at(0).toUpper() + tz.mid(1);
|
||||
const QString info_pattern = QStringLiteral("name:\"\\w+/%1\",info:\"([\\w=]+)\",extras:\"([\\w=]+)\"").arg(capitalized_tz);
|
||||
const QRegularExpression info_regex(info_pattern);
|
||||
const QRegularExpressionMatch info_match = info_regex.match(bundle);
|
||||
|
||||
if (!info_match.hasMatch()) {
|
||||
qLog(Debug) << "Qobuz: No info/extras found for timezone" << tz;
|
||||
continue;
|
||||
}
|
||||
|
||||
const QString seed = seeds[tz];
|
||||
const QString info = info_match.captured(1);
|
||||
const QString extras = info_match.captured(2);
|
||||
|
||||
qLog(Debug) << "Qobuz: Decoding secret for timezone" << tz;
|
||||
|
||||
// Combine seed + info + extras
|
||||
const QString combined = seed + info + extras;
|
||||
|
||||
// Remove last 44 characters
|
||||
if (combined.length() <= 44) {
|
||||
qLog(Debug) << "Qobuz: Combined string too short for timezone" << tz;
|
||||
continue;
|
||||
}
|
||||
const QString trimmed = combined.left(combined.length() - 44);
|
||||
|
||||
// Base64 decode
|
||||
const QByteArray decoded = QByteArray::fromBase64(trimmed.toLatin1());
|
||||
const QString secret = QString::fromLatin1(decoded);
|
||||
|
||||
// Validate: should be 32 hex characters
|
||||
static const QRegularExpression hex_regex(u"^[a-f0-9]{32}$"_s);
|
||||
if (hex_regex.match(secret).hasMatch()) {
|
||||
qLog(Debug) << "Qobuz: Successfully decoded secret from timezone" << tz;
|
||||
return secret;
|
||||
}
|
||||
|
||||
qLog(Debug) << "Qobuz: Decoded secret invalid for timezone" << tz;
|
||||
}
|
||||
|
||||
// Try any remaining timezones not in preferred order
|
||||
for (auto it = seeds.constBegin(); it != seeds.constEnd(); ++it) {
|
||||
const QString &tz = it.key();
|
||||
if (preferred_order.contains(tz)) {
|
||||
continue; // Already tried
|
||||
}
|
||||
|
||||
const QString capitalized_tz = tz.at(0).toUpper() + tz.mid(1);
|
||||
const QString info_pattern = QStringLiteral("name:\"\\w+/%1\",info:\"([\\w=]+)\",extras:\"([\\w=]+)\"").arg(capitalized_tz);
|
||||
const QRegularExpression info_regex(info_pattern);
|
||||
const QRegularExpressionMatch info_match = info_regex.match(bundle);
|
||||
|
||||
if (!info_match.hasMatch()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const QString seed = it.value();
|
||||
const QString info = info_match.captured(1);
|
||||
const QString extras = info_match.captured(2);
|
||||
|
||||
const QString combined = seed + info + extras;
|
||||
if (combined.length() <= 44) {
|
||||
continue;
|
||||
}
|
||||
const QString trimmed = combined.left(combined.length() - 44);
|
||||
|
||||
const QByteArray decoded = QByteArray::fromBase64(trimmed.toLatin1());
|
||||
const QString secret = QString::fromLatin1(decoded);
|
||||
|
||||
static const QRegularExpression hex_regex(u"^[a-f0-9]{32}$"_s);
|
||||
if (hex_regex.match(secret).hasMatch()) {
|
||||
qLog(Debug) << "Qobuz: Successfully decoded secret from timezone" << tz;
|
||||
return secret;
|
||||
}
|
||||
}
|
||||
|
||||
qLog(Error) << "Qobuz: Failed to decode any valid app_secret from bundle";
|
||||
return QString();
|
||||
|
||||
}
|
||||
59
src/qobuz/qobuzcredentialfetcher.h
Normal file
59
src/qobuz/qobuzcredentialfetcher.h
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Strawberry Music Player
|
||||
* Copyright 2019-2025, Jonas Kvinge <jonas@jkvinge.net>
|
||||
*
|
||||
* Strawberry is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Strawberry is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
#ifndef QOBUZCREDENTIALFETCHER_H
|
||||
#define QOBUZCREDENTIALFETCHER_H
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
#include "includes/shared_ptr.h"
|
||||
|
||||
class QNetworkReply;
|
||||
class NetworkAccessManager;
|
||||
|
||||
class QobuzCredentialFetcher : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit QobuzCredentialFetcher(const SharedPtr<NetworkAccessManager> network, QObject *parent = nullptr);
|
||||
|
||||
void FetchCredentials();
|
||||
|
||||
Q_SIGNALS:
|
||||
void CredentialsFetched(const QString &app_id, const QString &app_secret);
|
||||
void CredentialsFetchError(const QString &error);
|
||||
|
||||
private Q_SLOTS:
|
||||
void LoginPageReceived();
|
||||
void BundleReceived();
|
||||
|
||||
private:
|
||||
QString ExtractAppId(const QString &bundle);
|
||||
QString ExtractAppSecret(const QString &bundle);
|
||||
|
||||
const SharedPtr<NetworkAccessManager> network_;
|
||||
QNetworkReply *login_page_reply_;
|
||||
QNetworkReply *bundle_reply_;
|
||||
QString bundle_url_;
|
||||
};
|
||||
|
||||
#endif // QOBUZCREDENTIALFETCHER_H
|
||||
@@ -310,6 +310,10 @@ void QobuzService::HandleLoginSSLErrors(const QList<QSslError> &ssl_errors) {
|
||||
|
||||
void QobuzService::HandleAuthReply(QNetworkReply *reply) {
|
||||
|
||||
if (replies_.contains(reply)) {
|
||||
replies_.removeAll(reply);
|
||||
}
|
||||
QObject::disconnect(reply, nullptr, this, nullptr);
|
||||
reply->deleteLater();
|
||||
|
||||
login_sent_ = false;
|
||||
|
||||
@@ -106,6 +106,8 @@ class QobuzService : public StreamingService {
|
||||
bool login_sent() const { return login_sent_; }
|
||||
bool login_attempts() const { return login_attempts_; }
|
||||
|
||||
SharedPtr<NetworkAccessManager> network() const { return network_; }
|
||||
|
||||
uint GetStreamURL(const QUrl &url, QString &error);
|
||||
|
||||
SharedPtr<CollectionBackend> artists_collection_backend() override { return artists_collection_backend_; }
|
||||
|
||||
@@ -115,6 +115,7 @@ void QobuzStreamURLRequest::GetStreamURL() {
|
||||
const quint64 timestamp = static_cast<quint64>(QDateTime::currentSecsSinceEpoch());
|
||||
|
||||
ParamList params_to_sign = ParamList() << Param(u"format_id"_s, QString::number(service_->format()))
|
||||
<< Param(u"intent"_s, u"stream"_s)
|
||||
<< Param(u"track_id"_s, QString::number(song_id_));
|
||||
|
||||
std::sort(params_to_sign.begin(), params_to_sign.end());
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
#include "core/settings.h"
|
||||
#include "widgets/loginstatewidget.h"
|
||||
#include "qobuz/qobuzservice.h"
|
||||
#include "qobuz/qobuzcredentialfetcher.h"
|
||||
#include "constants/qobuzsettings.h"
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
@@ -46,13 +47,15 @@ using namespace QobuzSettings;
|
||||
QobuzSettingsPage::QobuzSettingsPage(SettingsDialog *dialog, const SharedPtr<QobuzService> service, QWidget *parent)
|
||||
: SettingsPage(dialog, parent),
|
||||
ui_(new Ui::QobuzSettingsPage),
|
||||
service_(service) {
|
||||
service_(service),
|
||||
credential_fetcher_(nullptr) {
|
||||
|
||||
ui_->setupUi(this);
|
||||
setWindowIcon(IconLoader::Load(u"qobuz"_s, true, 0, 32));
|
||||
|
||||
QObject::connect(ui_->button_login, &QPushButton::clicked, this, &QobuzSettingsPage::LoginClicked);
|
||||
QObject::connect(ui_->login_state, &LoginStateWidget::LogoutClicked, this, &QobuzSettingsPage::LogoutClicked);
|
||||
QObject::connect(ui_->button_fetch_credentials, &QPushButton::clicked, this, &QobuzSettingsPage::FetchCredentialsClicked);
|
||||
|
||||
QObject::connect(this, &QobuzSettingsPage::Login, &*service_, &StreamingService::LoginWithCredentials);
|
||||
|
||||
@@ -186,3 +189,40 @@ void QobuzSettingsPage::LoginFailure(const QString &failure_reason) {
|
||||
QMessageBox::warning(this, tr("Authentication failed"), failure_reason);
|
||||
|
||||
}
|
||||
|
||||
void QobuzSettingsPage::FetchCredentialsClicked() {
|
||||
|
||||
ui_->button_fetch_credentials->setEnabled(false);
|
||||
ui_->button_fetch_credentials->setText(tr("Fetching..."));
|
||||
|
||||
if (!credential_fetcher_) {
|
||||
credential_fetcher_ = new QobuzCredentialFetcher(service_->network(), this);
|
||||
QObject::connect(credential_fetcher_, &QobuzCredentialFetcher::CredentialsFetched, this, &QobuzSettingsPage::CredentialsFetched);
|
||||
QObject::connect(credential_fetcher_, &QobuzCredentialFetcher::CredentialsFetchError, this, &QobuzSettingsPage::CredentialsFetchError);
|
||||
}
|
||||
|
||||
credential_fetcher_->FetchCredentials();
|
||||
|
||||
}
|
||||
|
||||
void QobuzSettingsPage::CredentialsFetched(const QString &app_id, const QString &app_secret) {
|
||||
|
||||
ui_->app_id->setText(app_id);
|
||||
ui_->app_secret->setText(app_secret);
|
||||
ui_->checkbox_base64_secret->setChecked(false);
|
||||
|
||||
ui_->button_fetch_credentials->setEnabled(true);
|
||||
ui_->button_fetch_credentials->setText(tr("Fetch Credentials"));
|
||||
|
||||
QMessageBox::information(this, tr("Credentials fetched"), tr("App ID and secret have been successfully fetched from the Qobuz web player."));
|
||||
|
||||
}
|
||||
|
||||
void QobuzSettingsPage::CredentialsFetchError(const QString &error) {
|
||||
|
||||
ui_->button_fetch_credentials->setEnabled(true);
|
||||
ui_->button_fetch_credentials->setText(tr("Fetch Credentials"));
|
||||
|
||||
QMessageBox::warning(this, tr("Credential fetch failed"), error);
|
||||
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ class QShowEvent;
|
||||
class QEvent;
|
||||
class SettingsDialog;
|
||||
class QobuzService;
|
||||
class QobuzCredentialFetcher;
|
||||
class Ui_QobuzSettingsPage;
|
||||
|
||||
class QobuzSettingsPage : public SettingsPage {
|
||||
@@ -55,10 +56,14 @@ class QobuzSettingsPage : public SettingsPage {
|
||||
void LogoutClicked();
|
||||
void LoginSuccess();
|
||||
void LoginFailure(const QString &failure_reason);
|
||||
void FetchCredentialsClicked();
|
||||
void CredentialsFetched(const QString &app_id, const QString &app_secret);
|
||||
void CredentialsFetchError(const QString &error);
|
||||
|
||||
private:
|
||||
Ui_QobuzSettingsPage *ui_;
|
||||
const SharedPtr<QobuzService> service_;
|
||||
QobuzCredentialFetcher *credential_fetcher_;
|
||||
};
|
||||
|
||||
#endif // QOBUZSETTINGSPAGE_H
|
||||
|
||||
@@ -115,6 +115,16 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="1">
|
||||
<widget class="QPushButton" name="button_fetch_credentials">
|
||||
<property name="text">
|
||||
<string>Fetch Credentials</string>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Automatically fetch app ID and secret from Qobuz web player</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
</item>
|
||||
|
||||
@@ -134,13 +134,10 @@ void SmartPlaylistsModel::Init() {
|
||||
|
||||
// Append the new ones
|
||||
s.beginWriteArray(collection_backend_->songs_table(), playlist_index + unwritten_defaults);
|
||||
for (; version < default_smart_playlists_.count(); ++version) {
|
||||
const GeneratorList generators = default_smart_playlists_.value(version);
|
||||
for (PlaylistGeneratorPtr gen : generators) {
|
||||
SaveGenerator(&s, playlist_index++, gen);
|
||||
}
|
||||
}
|
||||
WriteDefaultsToSettings(&s, version, playlist_index);
|
||||
s.endArray();
|
||||
|
||||
version = default_smart_playlists_.count();
|
||||
}
|
||||
|
||||
s.setValue(collection_backend_->songs_table() + u"_version"_s, version);
|
||||
@@ -269,6 +266,46 @@ PlaylistGeneratorPtr SmartPlaylistsModel::CreateGenerator(const QModelIndex &idx
|
||||
|
||||
}
|
||||
|
||||
void SmartPlaylistsModel::WriteDefaultsToSettings(Settings *s, const int start_version, const int start_index) {
|
||||
|
||||
int playlist_index = start_index;
|
||||
for (int version = start_version; version < default_smart_playlists_.count(); ++version) {
|
||||
const GeneratorList generators = default_smart_playlists_.value(version);
|
||||
for (PlaylistGeneratorPtr gen : generators) {
|
||||
SaveGenerator(s, playlist_index++, gen);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
void SmartPlaylistsModel::RestoreDefaults() {
|
||||
|
||||
root_->ClearNotify();
|
||||
|
||||
Settings s;
|
||||
s.beginGroup(kSettingsGroup);
|
||||
|
||||
int total_defaults = 0;
|
||||
for (const GeneratorList &generators : default_smart_playlists_) {
|
||||
total_defaults += static_cast<int>(generators.count());
|
||||
}
|
||||
|
||||
s.beginWriteArray(collection_backend_->songs_table(), total_defaults);
|
||||
WriteDefaultsToSettings(&s, 0, 0);
|
||||
s.endArray();
|
||||
|
||||
s.setValue(collection_backend_->songs_table() + u"_version"_s, default_smart_playlists_.count());
|
||||
|
||||
const int count = s.beginReadArray(collection_backend_->songs_table());
|
||||
for (int i = 0; i < count; ++i) {
|
||||
s.setArrayIndex(i);
|
||||
ItemFromSmartPlaylist(s, true);
|
||||
}
|
||||
s.endArray();
|
||||
s.endGroup();
|
||||
|
||||
}
|
||||
|
||||
QVariant SmartPlaylistsModel::data(const QModelIndex &idx, const int role) const {
|
||||
|
||||
if (!idx.isValid()) return QVariant();
|
||||
|
||||
@@ -66,6 +66,7 @@ class SmartPlaylistsModel : public SimpleTreeModel<SmartPlaylistsItem> {
|
||||
void AddGenerator(PlaylistGeneratorPtr gen);
|
||||
void UpdateGenerator(const QModelIndex &idx, PlaylistGeneratorPtr gen);
|
||||
void DeleteGenerator(const QModelIndex &idx);
|
||||
void RestoreDefaults();
|
||||
|
||||
private:
|
||||
QVariant data(const QModelIndex &idx, int role = Qt::DisplayRole) const override;
|
||||
@@ -79,6 +80,7 @@ class SmartPlaylistsModel : public SimpleTreeModel<SmartPlaylistsItem> {
|
||||
|
||||
static void SaveGenerator(Settings *s, const int i, PlaylistGeneratorPtr generator);
|
||||
void ItemFromSmartPlaylist(const Settings &s, const bool notify);
|
||||
void WriteDefaultsToSettings(Settings *s, const int start_version, const int start_index);
|
||||
|
||||
private:
|
||||
SharedPtr<CollectionBackend> collection_backend_;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include <QMenu>
|
||||
#include <QSettings>
|
||||
#include <QShowEvent>
|
||||
#include <QMessageBox>
|
||||
|
||||
#include "core/iconloader.h"
|
||||
#include "core/mimedata.h"
|
||||
@@ -60,6 +61,7 @@ SmartPlaylistsViewContainer::SmartPlaylistsViewContainer(const SharedPtr<Player>
|
||||
action_new_smart_playlist_(nullptr),
|
||||
action_edit_smart_playlist_(nullptr),
|
||||
action_delete_smart_playlist_(nullptr),
|
||||
action_restore_defaults_(nullptr),
|
||||
action_append_to_playlist_(nullptr),
|
||||
action_replace_current_playlist_(nullptr),
|
||||
action_open_in_new_playlist_(nullptr),
|
||||
@@ -74,6 +76,7 @@ SmartPlaylistsViewContainer::SmartPlaylistsViewContainer(const SharedPtr<Player>
|
||||
model_->Init();
|
||||
|
||||
action_new_smart_playlist_ = context_menu_->addAction(IconLoader::Load(u"document-new"_s), tr("New smart playlist..."), this, &SmartPlaylistsViewContainer::NewSmartPlaylist);
|
||||
action_restore_defaults_ = context_menu_->addAction(IconLoader::Load(u"view-refresh"_s), tr("Restore defaults"), this, &SmartPlaylistsViewContainer::RestoreDefaultsFromContext);
|
||||
|
||||
action_append_to_playlist_ = context_menu_selected_->addAction(IconLoader::Load(u"media-playback-start"_s), tr("Append to current playlist"), this, &SmartPlaylistsViewContainer::AppendToPlaylist);
|
||||
action_replace_current_playlist_ = context_menu_selected_->addAction(IconLoader::Load(u"media-playback-start"_s), tr("Replace current playlist"), this, &SmartPlaylistsViewContainer::ReplaceCurrentPlaylist);
|
||||
@@ -90,13 +93,16 @@ SmartPlaylistsViewContainer::SmartPlaylistsViewContainer(const SharedPtr<Player>
|
||||
action_delete_smart_playlist_ = context_menu_selected_->addAction(IconLoader::Load(u"edit-delete"_s), tr("Delete smart playlist"), this, &SmartPlaylistsViewContainer::DeleteSmartPlaylistFromContext);
|
||||
|
||||
context_menu_selected_->addSeparator();
|
||||
context_menu_selected_->addAction(action_restore_defaults_);
|
||||
|
||||
ui_->new_->setDefaultAction(action_new_smart_playlist_);
|
||||
ui_->edit_->setIcon(IconLoader::Load(u"edit-rename"_s));
|
||||
ui_->delete_->setIcon(IconLoader::Load(u"edit-delete"_s));
|
||||
ui_->restore_->setIcon(IconLoader::Load(u"view-refresh"_s));
|
||||
|
||||
QObject::connect(ui_->edit_, &QToolButton::clicked, this, &SmartPlaylistsViewContainer::EditSmartPlaylistFromButton);
|
||||
QObject::connect(ui_->delete_, &QToolButton::clicked, this, &SmartPlaylistsViewContainer::DeleteSmartPlaylistFromButton);
|
||||
QObject::connect(ui_->restore_, &QToolButton::clicked, this, &SmartPlaylistsViewContainer::RestoreDefaults);
|
||||
|
||||
QObject::connect(ui_->view, &SmartPlaylistsView::ItemsSelectedChanged, this, &SmartPlaylistsViewContainer::ItemsSelectedChanged);
|
||||
QObject::connect(ui_->view, &SmartPlaylistsView::doubleClicked, this, &SmartPlaylistsViewContainer::ItemDoubleClicked);
|
||||
@@ -130,6 +136,7 @@ void SmartPlaylistsViewContainer::ReloadSettings() {
|
||||
ui_->new_->setIconSize(QSize(iconsize, iconsize));
|
||||
ui_->delete_->setIconSize(QSize(iconsize, iconsize));
|
||||
ui_->edit_->setIconSize(QSize(iconsize, iconsize));
|
||||
ui_->restore_->setIconSize(QSize(iconsize, iconsize));
|
||||
|
||||
}
|
||||
|
||||
@@ -304,3 +311,18 @@ void SmartPlaylistsViewContainer::ItemDoubleClicked(const QModelIndex &idx) {
|
||||
Q_EMIT AddToPlaylist(q_mimedata);
|
||||
|
||||
}
|
||||
|
||||
void SmartPlaylistsViewContainer::RestoreDefaultsFromContext() {
|
||||
|
||||
RestoreDefaults();
|
||||
|
||||
}
|
||||
|
||||
void SmartPlaylistsViewContainer::RestoreDefaults() {
|
||||
|
||||
const QMessageBox::StandardButton messagebox_answer = QMessageBox::question(this, tr("Restore defaults"), tr("Are you sure you want to restore the default smart playlists? This will remove all custom smart playlists"), QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
|
||||
if (messagebox_answer == QMessageBox::Yes) {
|
||||
model_->RestoreDefaults();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -83,11 +83,13 @@ class SmartPlaylistsViewContainer : public QWidget {
|
||||
|
||||
void EditSmartPlaylist(const QModelIndex &idx);
|
||||
void DeleteSmartPlaylist(const QModelIndex &idx);
|
||||
void RestoreDefaults();
|
||||
|
||||
void EditSmartPlaylistFromButton();
|
||||
void DeleteSmartPlaylistFromButton();
|
||||
void EditSmartPlaylistFromContext();
|
||||
void DeleteSmartPlaylistFromContext();
|
||||
void RestoreDefaultsFromContext();
|
||||
|
||||
void NewSmartPlaylistFinished();
|
||||
void EditSmartPlaylistFinished();
|
||||
@@ -113,6 +115,7 @@ class SmartPlaylistsViewContainer : public QWidget {
|
||||
QAction *action_new_smart_playlist_;
|
||||
QAction *action_edit_smart_playlist_;
|
||||
QAction *action_delete_smart_playlist_;
|
||||
QAction *action_restore_defaults_;
|
||||
QAction *action_append_to_playlist_;
|
||||
QAction *action_replace_current_playlist_;
|
||||
QAction *action_open_in_new_playlist_;
|
||||
|
||||
@@ -95,6 +95,19 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QToolButton" name="restore_">
|
||||
<property name="toolTip">
|
||||
<string>Restore defaults</string>
|
||||
</property>
|
||||
<property name="iconSize">
|
||||
<size>
|
||||
<width>22</width>
|
||||
<height>22</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="spacer_buttons">
|
||||
<property name="orientation">
|
||||
|
||||
29
src/tagreader/tagid3v2version.h
Normal file
29
src/tagreader/tagid3v2version.h
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Strawberry Music Player
|
||||
* Copyright 2025, Jonas Kvinge <jonas@jkvinge.net>
|
||||
*
|
||||
* Strawberry is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Strawberry is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with Strawberry. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
#ifndef TAGID3V2VERSION_H
|
||||
#define TAGID3V2VERSION_H
|
||||
|
||||
enum class TagID3v2Version {
|
||||
Default = 0, // Use existing version or library default
|
||||
V3 = 3,
|
||||
V4 = 4
|
||||
};
|
||||
|
||||
#endif // TAGID3V2VERSION_H
|
||||
@@ -32,6 +32,7 @@
|
||||
#include "savetagsoptions.h"
|
||||
#include "savetagcoverdata.h"
|
||||
#include "albumcovertagdata.h"
|
||||
#include "tagid3v2version.h"
|
||||
|
||||
class TagReaderBase {
|
||||
public:
|
||||
@@ -45,7 +46,7 @@ class TagReaderBase {
|
||||
virtual TagReaderResult ReadStream(const QUrl &url, const QString &filename, const quint64 size, const quint64 mtime, const QString &token_type, const QString &access_token, Song *song) const = 0;
|
||||
#endif
|
||||
|
||||
virtual TagReaderResult WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data) const = 0;
|
||||
virtual TagReaderResult WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data, const TagID3v2Version id3v2_version) const = 0;
|
||||
|
||||
virtual TagReaderResult LoadEmbeddedCover(const QString &filename, QByteArray &data) const = 0;
|
||||
virtual TagReaderResult SaveEmbeddedCover(const QString &filename, const SaveTagCoverData &save_tag_cover_data) const = 0;
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
#include "tagreaderreadstreamreply.h"
|
||||
#include "tagreaderloadcoverdatareply.h"
|
||||
#include "tagreaderloadcoverimagereply.h"
|
||||
#include "tagid3v2version.h"
|
||||
|
||||
using std::dynamic_pointer_cast;
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
@@ -189,7 +190,7 @@ void TagReaderClient::ProcessRequest(TagReaderRequestPtr request) {
|
||||
}
|
||||
#endif // HAVE_STREAMTAGREADER
|
||||
else if (TagReaderWriteFileRequestPtr write_file_request = dynamic_pointer_cast<TagReaderWriteFileRequest>(request)) {
|
||||
result = WriteFileBlocking(write_file_request->filename, write_file_request->song, write_file_request->save_tags_options, write_file_request->save_tag_cover_data);
|
||||
result = WriteFileBlocking(write_file_request->filename, write_file_request->song, write_file_request->save_tags_options, write_file_request->save_tag_cover_data, write_file_request->tag_id3v2_version);
|
||||
}
|
||||
else if (TagReaderLoadCoverDataRequestPtr load_cover_data_request = dynamic_pointer_cast<TagReaderLoadCoverDataRequest>(request)) {
|
||||
QByteArray cover_data;
|
||||
@@ -303,13 +304,13 @@ TagReaderReadStreamReplyPtr TagReaderClient::ReadStreamAsync(const QUrl &url, co
|
||||
}
|
||||
#endif // HAVE_STREAMTAGREADER
|
||||
|
||||
TagReaderResult TagReaderClient::WriteFileBlocking(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data) {
|
||||
TagReaderResult TagReaderClient::WriteFileBlocking(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data, const TagID3v2Version tag_id3v2_version) {
|
||||
|
||||
return tagreader_.WriteFile(filename, song, save_tags_options, save_tag_cover_data);
|
||||
return tagreader_.WriteFile(filename, song, save_tags_options, save_tag_cover_data, tag_id3v2_version);
|
||||
|
||||
}
|
||||
|
||||
TagReaderReplyPtr TagReaderClient::WriteFileAsync(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data) {
|
||||
TagReaderReplyPtr TagReaderClient::WriteFileAsync(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data, const TagID3v2Version tag_id3v2_version) {
|
||||
|
||||
Q_ASSERT(QThread::currentThread() != thread());
|
||||
|
||||
@@ -321,6 +322,7 @@ TagReaderReplyPtr TagReaderClient::WriteFileAsync(const QString &filename, const
|
||||
request->song = song;
|
||||
request->save_tags_options = save_tags_options;
|
||||
request->save_tag_cover_data = save_tag_cover_data;
|
||||
request->tag_id3v2_version = tag_id3v2_version;
|
||||
|
||||
EnqueueRequest(request);
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
#include "tagreaderloadcoverimagereply.h"
|
||||
#include "savetagsoptions.h"
|
||||
#include "savetagcoverdata.h"
|
||||
#include "tagid3v2version.h"
|
||||
|
||||
class QThread;
|
||||
class Song;
|
||||
@@ -72,8 +73,8 @@ class TagReaderClient : public QObject {
|
||||
[[nodiscard]] TagReaderReadStreamReplyPtr ReadStreamAsync(const QUrl &url, const QString &filename, const quint64 size, const quint64 mtime, const QString &token_type, const QString &access_token);
|
||||
#endif
|
||||
|
||||
TagReaderResult WriteFileBlocking(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options = SaveTagsOption::Tags, const SaveTagCoverData &save_tag_cover_data = SaveTagCoverData());
|
||||
[[nodiscard]] TagReaderReplyPtr WriteFileAsync(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options = SaveTagsOption::Tags, const SaveTagCoverData &save_tag_cover_data = SaveTagCoverData());
|
||||
TagReaderResult WriteFileBlocking(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options = SaveTagsOption::Tags, const SaveTagCoverData &save_tag_cover_data = SaveTagCoverData(), const TagID3v2Version tag_id3v2_version = TagID3v2Version::Default);
|
||||
[[nodiscard]] TagReaderReplyPtr WriteFileAsync(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options = SaveTagsOption::Tags, const SaveTagCoverData &save_tag_cover_data = SaveTagCoverData(), const TagID3v2Version tag_id3v2_version = TagID3v2Version::Default);
|
||||
|
||||
TagReaderResult LoadCoverDataBlocking(const QString &filename, QByteArray &data);
|
||||
TagReaderResult LoadCoverImageBlocking(const QString &filename, QImage &image);
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
#include "core/logging.h"
|
||||
#include "tagreaderbase.h"
|
||||
#include "tagreadertaglib.h"
|
||||
#include "tagid3v2version.h"
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
@@ -317,12 +318,13 @@ TagReaderResult TagReaderGME::ReadStream(const QUrl &url, const QString &filenam
|
||||
}
|
||||
#endif
|
||||
|
||||
TagReaderResult TagReaderGME::WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data) const {
|
||||
TagReaderResult TagReaderGME::WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data, const TagID3v2Version id3v2_version) const {
|
||||
|
||||
Q_UNUSED(filename);
|
||||
Q_UNUSED(song);
|
||||
Q_UNUSED(save_tags_options);
|
||||
Q_UNUSED(save_tag_cover_data);
|
||||
Q_UNUSED(id3v2_version);
|
||||
|
||||
return TagReaderResult::ErrorCode::Unsupported;
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
#include <QFileInfo>
|
||||
|
||||
#include "tagreaderbase.h"
|
||||
#include "tagid3v2version.h"
|
||||
|
||||
namespace GME {
|
||||
bool IsSupportedFormat(const QFileInfo &fileinfo);
|
||||
@@ -107,7 +108,7 @@ class TagReaderGME : public TagReaderBase {
|
||||
TagReaderResult ReadStream(const QUrl &url, const QString &filename, const quint64 size, const quint64 mtime, const QString &token_type, const QString &access_token, Song *song) const override;
|
||||
#endif
|
||||
|
||||
TagReaderResult WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data) const override;
|
||||
TagReaderResult WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data, const TagID3v2Version id3v2_version) const override;
|
||||
|
||||
TagReaderResult LoadEmbeddedCover(const QString &filename, QByteArray &data) const override;
|
||||
TagReaderResult SaveEmbeddedCover(const QString &filename, const SaveTagCoverData &save_tag_cover_data) const override;
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
#include <taglib/apeproperties.h>
|
||||
#include <taglib/id3v2tag.h>
|
||||
#include <taglib/id3v2frame.h>
|
||||
#include <taglib/id3v2header.h>
|
||||
#include <taglib/attachedpictureframe.h>
|
||||
#include <taglib/textidentificationframe.h>
|
||||
#include <taglib/unsynchronizedlyricsframe.h>
|
||||
@@ -94,8 +95,6 @@
|
||||
#include <QStringList>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QDir>
|
||||
#include <QTemporaryFile>
|
||||
#include <QUrl>
|
||||
#include <QDateTime>
|
||||
#include <QtDebug>
|
||||
@@ -106,6 +105,7 @@
|
||||
#include "constants/timeconstants.h"
|
||||
|
||||
#include "albumcovertagdata.h"
|
||||
#include "tagid3v2version.h"
|
||||
|
||||
using std::make_unique;
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
@@ -296,100 +296,6 @@ TagReaderTagLib::~TagReaderTagLib() {
|
||||
delete factory_;
|
||||
}
|
||||
|
||||
bool TagReaderTagLib::SaveFileWithFallback(const QString &filename, const std::function<bool(TagLib::FileRef*)> &save_function) const {
|
||||
|
||||
// First, try the normal save operation directly on the file
|
||||
{
|
||||
ScopedPtr<TagLib::FileRef> fileref(factory_->GetFileRef(filename));
|
||||
if (!fileref || fileref->isNull()) {
|
||||
qLog(Error) << "TagLib could not open file for saving" << filename;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply modifications using the callback
|
||||
if (!save_function(fileref.get())) {
|
||||
qLog(Error) << "Failed to apply modifications to file" << filename;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try direct save first
|
||||
if (fileref->save()) {
|
||||
qLog(Debug) << "Successfully saved file directly" << filename;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
qLog(Warning) << "Direct save failed, trying atomic write workaround for" << filename;
|
||||
|
||||
// If direct save fails (common on GVFS mounts), use atomic write workaround:
|
||||
// 1. Copy file to temporary location in the same directory
|
||||
// 2. Re-apply modifications and save to the temporary file
|
||||
// 3. Replace original file with the temporary file
|
||||
|
||||
const QFileInfo file_info(filename);
|
||||
const QString temp_pattern = file_info.dir().absoluteFilePath(file_info.fileName() + u".XXXXXX"_s);
|
||||
|
||||
QTemporaryFile temp_file(temp_pattern);
|
||||
temp_file.setAutoRemove(false); // We'll handle removal manually
|
||||
|
||||
if (!temp_file.open()) {
|
||||
qLog(Error) << "Could not create temporary file for atomic write:" << temp_file.fileName();
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString temp_filename = temp_file.fileName();
|
||||
temp_file.close();
|
||||
|
||||
// Copy original file to temporary location
|
||||
if (!QFile::copy(filename, temp_filename)) {
|
||||
qLog(Error) << "Could not copy file to temporary location:" << filename << "->" << temp_filename;
|
||||
QFile::remove(temp_filename);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to modify and save the temporary file
|
||||
{
|
||||
ScopedPtr<TagLib::FileRef> temp_fileref(factory_->GetFileRef(temp_filename));
|
||||
if (!temp_fileref || temp_fileref->isNull()) {
|
||||
qLog(Error) << "TagLib could not open temporary file" << temp_filename;
|
||||
QFile::remove(temp_filename);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply modifications using the callback
|
||||
if (!save_function(temp_fileref.get())) {
|
||||
qLog(Error) << "Failed to apply modifications to temporary file" << temp_filename;
|
||||
QFile::remove(temp_filename);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Save the temporary file
|
||||
if (!temp_fileref->save()) {
|
||||
qLog(Error) << "Failed to save temporary file" << temp_filename;
|
||||
QFile::remove(temp_filename);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the original file with the temporary file
|
||||
// First remove the original, then rename temp to original
|
||||
if (!QFile::remove(filename)) {
|
||||
qLog(Error) << "Could not remove original file for replacement:" << filename;
|
||||
QFile::remove(temp_filename);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!QFile::rename(temp_filename, filename)) {
|
||||
qLog(Error) << "Could not rename temporary file to original:" << temp_filename << "->" << filename;
|
||||
// This is a critical error - original file was removed but rename failed
|
||||
return false;
|
||||
}
|
||||
|
||||
qLog(Debug) << "Successfully saved file using atomic write workaround" << filename;
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
TagReaderResult TagReaderTagLib::IsMediaFile(const QString &filename) const {
|
||||
|
||||
qLog(Debug) << "Checking for valid file" << filename;
|
||||
@@ -700,8 +606,14 @@ TagReaderResult TagReaderTagLib::ReadStream(const QUrl &url,
|
||||
|
||||
void TagReaderTagLib::ParseID3v2Tags(TagLib::ID3v2::Tag *tag, QString *disc, QString *compilation, Song *song) const {
|
||||
|
||||
if (!tag) return;
|
||||
|
||||
TagLib::ID3v2::FrameListMap map = tag->frameListMap();
|
||||
|
||||
if (tag->header()) {
|
||||
song->set_id3v2_version(tag->header()->majorVersion());
|
||||
}
|
||||
|
||||
if (map.contains(kID3v2_Disc)) *disc = TagLibStringToQString(map[kID3v2_Disc].front()->toString()).trimmed();
|
||||
if (map.contains(kID3v2_Composer)) song->set_composer(map[kID3v2_Composer].front()->toString());
|
||||
if (map.contains(kID3v2_ComposerSort)) song->set_composersort(map[kID3v2_ComposerSort].front()->toString());
|
||||
@@ -1138,7 +1050,7 @@ void TagReaderTagLib::ParseASFAttribute(const TagLib::ASF::AttributeListMap &att
|
||||
|
||||
}
|
||||
|
||||
TagReaderResult TagReaderTagLib::WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data) const {
|
||||
TagReaderResult TagReaderTagLib::WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data, const TagID3v2Version tag_id3v2_version) const {
|
||||
|
||||
if (filename.isEmpty()) {
|
||||
return TagReaderResult::ErrorCode::FilenameMissing;
|
||||
@@ -1175,26 +1087,175 @@ TagReaderResult TagReaderTagLib::WriteFile(const QString &filename, const Song &
|
||||
cover = LoadAlbumCoverTagData(filename, save_tag_cover_data);
|
||||
}
|
||||
|
||||
// Lambda function that applies all tag modifications to a FileRef
|
||||
auto apply_modifications = [&](TagLib::FileRef *fileref) -> bool {
|
||||
if (!fileref || fileref->isNull()) {
|
||||
return false;
|
||||
}
|
||||
ScopedPtr<TagLib::FileRef> fileref(factory_->GetFileRef(filename));
|
||||
if (!fileref || fileref->isNull()) {
|
||||
qLog(Error) << "TagLib could not open file" << filename;
|
||||
return TagReaderResult::ErrorCode::FileOpenError;
|
||||
}
|
||||
|
||||
if (save_tags) {
|
||||
fileref->tag()->setTitle(song.title().isEmpty() ? TagLib::String() : QStringToTagLibString(song.title()));
|
||||
fileref->tag()->setArtist(song.artist().isEmpty() ? TagLib::String() : QStringToTagLibString(song.artist()));
|
||||
fileref->tag()->setAlbum(song.album().isEmpty() ? TagLib::String() : QStringToTagLibString(song.album()));
|
||||
fileref->tag()->setGenre(song.genre().isEmpty() ? TagLib::String() : QStringToTagLibString(song.genre()));
|
||||
fileref->tag()->setComment(song.comment().isEmpty() ? TagLib::String() : QStringToTagLibString(song.comment()));
|
||||
fileref->tag()->setYear(song.year() <= 0 ? 0 : static_cast<uint>(song.year()));
|
||||
fileref->tag()->setTrack(song.track() <= 0 ? 0 : static_cast<uint>(song.track()));
|
||||
}
|
||||
if (save_tags) {
|
||||
fileref->tag()->setTitle(song.title().isEmpty() ? TagLib::String() : QStringToTagLibString(song.title()));
|
||||
fileref->tag()->setArtist(song.artist().isEmpty() ? TagLib::String() : QStringToTagLibString(song.artist()));
|
||||
fileref->tag()->setAlbum(song.album().isEmpty() ? TagLib::String() : QStringToTagLibString(song.album()));
|
||||
fileref->tag()->setGenre(song.genre().isEmpty() ? TagLib::String() : QStringToTagLibString(song.genre()));
|
||||
fileref->tag()->setComment(song.comment().isEmpty() ? TagLib::String() : QStringToTagLibString(song.comment()));
|
||||
fileref->tag()->setYear(song.year() <= 0 ? 0 : static_cast<uint>(song.year()));
|
||||
fileref->tag()->setTrack(song.track() <= 0 ? 0 : static_cast<uint>(song.track()));
|
||||
}
|
||||
|
||||
bool is_flac = false;
|
||||
if (TagLib::FLAC::File *file_flac = dynamic_cast<TagLib::FLAC::File*>(fileref->file())) {
|
||||
is_flac = true;
|
||||
TagLib::Ogg::XiphComment *vorbis_comment = file_flac->xiphComment(true);
|
||||
bool is_flac = false;
|
||||
if (TagLib::FLAC::File *file_flac = dynamic_cast<TagLib::FLAC::File*>(fileref->file())) {
|
||||
is_flac = true;
|
||||
TagLib::Ogg::XiphComment *vorbis_comment = file_flac->xiphComment(true);
|
||||
if (vorbis_comment) {
|
||||
if (save_tags) {
|
||||
SetVorbisComments(vorbis_comment, song);
|
||||
}
|
||||
if (save_playcount) {
|
||||
SetPlaycount(vorbis_comment, song.playcount());
|
||||
}
|
||||
if (save_rating) {
|
||||
SetRating(vorbis_comment, song.rating());
|
||||
}
|
||||
if (save_cover) {
|
||||
SetEmbeddedCover(file_flac, vorbis_comment, cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else if (TagLib::WavPack::File *file_wavpack = dynamic_cast<TagLib::WavPack::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = file_wavpack->APETag(true);
|
||||
if (tag) {
|
||||
if (save_tags) {
|
||||
SetAPETag(tag, song);
|
||||
}
|
||||
if (save_playcount) {
|
||||
SetPlaycount(tag, song.playcount());
|
||||
}
|
||||
if (save_rating) {
|
||||
SetRating(tag, song.rating());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else if (TagLib::APE::File *file_ape = dynamic_cast<TagLib::APE::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = file_ape->APETag(true);
|
||||
if (tag) {
|
||||
if (save_tags) {
|
||||
SetAPETag(tag, song);
|
||||
}
|
||||
if (save_playcount) {
|
||||
SetPlaycount(tag, song.playcount());
|
||||
}
|
||||
if (save_rating) {
|
||||
SetRating(tag, song.rating());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else if (TagLib::MPC::File *file_mpc = dynamic_cast<TagLib::MPC::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = file_mpc->APETag(true);
|
||||
if (tag) {
|
||||
if (save_tags) {
|
||||
SetAPETag(tag, song);
|
||||
}
|
||||
if (save_playcount) {
|
||||
SetPlaycount(tag, song.playcount());
|
||||
}
|
||||
if (save_rating) {
|
||||
SetRating(tag, song.rating());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else if (TagLib::MPEG::File *file_mpeg = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
|
||||
TagLib::ID3v2::Tag *tag = file_mpeg->ID3v2Tag(true);
|
||||
if (tag) {
|
||||
if (save_tags) {
|
||||
SetID3v2Tag(tag, song);
|
||||
}
|
||||
if (save_playcount) {
|
||||
SetPlaycount(tag, song.playcount());
|
||||
}
|
||||
if (save_rating) {
|
||||
SetRating(tag, song.rating());
|
||||
}
|
||||
if (save_cover) {
|
||||
SetEmbeddedCover(tag, cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else if (TagLib::MP4::File *file_mp4 = dynamic_cast<TagLib::MP4::File*>(fileref->file())) {
|
||||
TagLib::MP4::Tag *tag = file_mp4->tag();
|
||||
if (tag) {
|
||||
if (save_tags) {
|
||||
tag->setItem(kMP4_Disc, TagLib::MP4::Item(song.disc() <= 0 - 1 ? 0 : song.disc(), 0));
|
||||
tag->setItem(kMP4_Composer, TagLib::StringList(QStringToTagLibString(song.composer())));
|
||||
tag->setItem(kMP4_Grouping, TagLib::StringList(QStringToTagLibString(song.grouping())));
|
||||
tag->setItem(kMP4_Lyrics, TagLib::StringList(QStringToTagLibString(song.lyrics())));
|
||||
tag->setItem(kMP4_AlbumArtist, TagLib::StringList(QStringToTagLibString(song.albumartist())));
|
||||
tag->setItem(kMP4_Compilation, TagLib::MP4::Item(song.compilation()));
|
||||
}
|
||||
if (save_playcount) {
|
||||
SetPlaycount(tag, song.playcount());
|
||||
}
|
||||
if (save_rating) {
|
||||
SetRating(tag, song.rating());
|
||||
}
|
||||
if (save_cover) {
|
||||
SetEmbeddedCover(file_mp4, tag, cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else if (TagLib::RIFF::WAV::File *file_wav = dynamic_cast<TagLib::RIFF::WAV::File*>(fileref->file())) {
|
||||
TagLib::ID3v2::Tag *tag = file_wav->ID3v2Tag();
|
||||
if (tag) {
|
||||
if (save_tags) {
|
||||
SetID3v2Tag(tag, song);
|
||||
}
|
||||
if (save_playcount) {
|
||||
SetPlaycount(tag, song.playcount());
|
||||
}
|
||||
if (save_rating) {
|
||||
SetRating(tag, song.rating());
|
||||
}
|
||||
if (save_cover) {
|
||||
SetEmbeddedCover(tag, cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else if (TagLib::RIFF::AIFF::File *file_aiff = dynamic_cast<TagLib::RIFF::AIFF::File*>(fileref->file())) {
|
||||
TagLib::ID3v2::Tag *tag = file_aiff->tag();
|
||||
if (tag) {
|
||||
if (save_tags) {
|
||||
SetID3v2Tag(tag, song);
|
||||
}
|
||||
if (save_playcount) {
|
||||
SetPlaycount(tag, song.playcount());
|
||||
}
|
||||
if (save_rating) {
|
||||
SetRating(tag, song.rating());
|
||||
}
|
||||
if (save_cover) {
|
||||
SetEmbeddedCover(tag, cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else if (TagLib::ASF::File *file_asf = dynamic_cast<TagLib::ASF::File*>(fileref->file())) {
|
||||
TagLib::ASF::Tag *tag = file_asf->tag();
|
||||
if (tag) {
|
||||
SetASFTag(tag, song);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle all the files which have VorbisComments (Ogg, OPUS, ...) in the same way;
|
||||
// apart, so we keep specific behavior for some formats by adding another "else if" block above.
|
||||
if (!is_flac) {
|
||||
if (TagLib::Ogg::XiphComment *vorbis_comment = dynamic_cast<TagLib::Ogg::XiphComment*>(fileref->file()->tag())) {
|
||||
if (vorbis_comment) {
|
||||
if (save_tags) {
|
||||
SetVorbisComments(vorbis_comment, song);
|
||||
@@ -1206,166 +1267,39 @@ TagReaderResult TagReaderTagLib::WriteFile(const QString &filename, const Song &
|
||||
SetRating(vorbis_comment, song.rating());
|
||||
}
|
||||
if (save_cover) {
|
||||
SetEmbeddedCover(file_flac, vorbis_comment, cover.data, cover.mimetype);
|
||||
SetEmbeddedCover(vorbis_comment, cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else if (TagLib::WavPack::File *file_wavpack = dynamic_cast<TagLib::WavPack::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = file_wavpack->APETag(true);
|
||||
if (tag) {
|
||||
if (save_tags) {
|
||||
SetAPETag(tag, song);
|
||||
}
|
||||
if (save_playcount) {
|
||||
SetPlaycount(tag, song.playcount());
|
||||
}
|
||||
if (save_rating) {
|
||||
SetRating(tag, song.rating());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Determine ID3v2 version to use and convert to TagLib type
|
||||
TagLib::ID3v2::Version taglib_id3v2_version = TagLib::ID3v2::v4;
|
||||
if (tag_id3v2_version == TagID3v2Version::V3) {
|
||||
taglib_id3v2_version = TagLib::ID3v2::v3;
|
||||
}
|
||||
else if (tag_id3v2_version == TagID3v2Version::V4) {
|
||||
taglib_id3v2_version = TagLib::ID3v2::v4;
|
||||
}
|
||||
|
||||
else if (TagLib::APE::File *file_ape = dynamic_cast<TagLib::APE::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = file_ape->APETag(true);
|
||||
if (tag) {
|
||||
if (save_tags) {
|
||||
SetAPETag(tag, song);
|
||||
}
|
||||
if (save_playcount) {
|
||||
SetPlaycount(tag, song.playcount());
|
||||
}
|
||||
if (save_rating) {
|
||||
SetRating(tag, song.rating());
|
||||
}
|
||||
}
|
||||
}
|
||||
bool success = false;
|
||||
|
||||
else if (TagLib::MPC::File *file_mpc = dynamic_cast<TagLib::MPC::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = file_mpc->APETag(true);
|
||||
if (tag) {
|
||||
if (save_tags) {
|
||||
SetAPETag(tag, song);
|
||||
}
|
||||
if (save_playcount) {
|
||||
SetPlaycount(tag, song.playcount());
|
||||
}
|
||||
if (save_rating) {
|
||||
SetRating(tag, song.rating());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else if (TagLib::MPEG::File *file_mpeg = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
|
||||
TagLib::ID3v2::Tag *tag = file_mpeg->ID3v2Tag(true);
|
||||
if (tag) {
|
||||
if (save_tags) {
|
||||
SetID3v2Tag(tag, song);
|
||||
}
|
||||
if (save_playcount) {
|
||||
SetPlaycount(tag, song.playcount());
|
||||
}
|
||||
if (save_rating) {
|
||||
SetRating(tag, song.rating());
|
||||
}
|
||||
if (save_cover) {
|
||||
SetEmbeddedCover(tag, cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else if (TagLib::MP4::File *file_mp4 = dynamic_cast<TagLib::MP4::File*>(fileref->file())) {
|
||||
TagLib::MP4::Tag *tag = file_mp4->tag();
|
||||
if (tag) {
|
||||
if (save_tags) {
|
||||
tag->setItem(kMP4_Disc, TagLib::MP4::Item(song.disc() <= 0 - 1 ? 0 : song.disc(), 0));
|
||||
tag->setItem(kMP4_Composer, TagLib::StringList(QStringToTagLibString(song.composer())));
|
||||
tag->setItem(kMP4_Grouping, TagLib::StringList(QStringToTagLibString(song.grouping())));
|
||||
tag->setItem(kMP4_Lyrics, TagLib::StringList(QStringToTagLibString(song.lyrics())));
|
||||
tag->setItem(kMP4_AlbumArtist, TagLib::StringList(QStringToTagLibString(song.albumartist())));
|
||||
tag->setItem(kMP4_Compilation, TagLib::MP4::Item(song.compilation()));
|
||||
}
|
||||
if (save_playcount) {
|
||||
SetPlaycount(tag, song.playcount());
|
||||
}
|
||||
if (save_rating) {
|
||||
SetRating(tag, song.rating());
|
||||
}
|
||||
if (save_cover) {
|
||||
SetEmbeddedCover(file_mp4, tag, cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else if (TagLib::RIFF::WAV::File *file_wav = dynamic_cast<TagLib::RIFF::WAV::File*>(fileref->file())) {
|
||||
TagLib::ID3v2::Tag *tag = file_wav->ID3v2Tag();
|
||||
if (tag) {
|
||||
if (save_tags) {
|
||||
SetID3v2Tag(tag, song);
|
||||
}
|
||||
if (save_playcount) {
|
||||
SetPlaycount(tag, song.playcount());
|
||||
}
|
||||
if (save_rating) {
|
||||
SetRating(tag, song.rating());
|
||||
}
|
||||
if (save_cover) {
|
||||
SetEmbeddedCover(tag, cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else if (TagLib::RIFF::AIFF::File *file_aiff = dynamic_cast<TagLib::RIFF::AIFF::File*>(fileref->file())) {
|
||||
TagLib::ID3v2::Tag *tag = file_aiff->tag();
|
||||
if (tag) {
|
||||
if (save_tags) {
|
||||
SetID3v2Tag(tag, song);
|
||||
}
|
||||
if (save_playcount) {
|
||||
SetPlaycount(tag, song.playcount());
|
||||
}
|
||||
if (save_rating) {
|
||||
SetRating(tag, song.rating());
|
||||
}
|
||||
if (save_cover) {
|
||||
SetEmbeddedCover(tag, cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else if (TagLib::ASF::File *file_asf = dynamic_cast<TagLib::ASF::File*>(fileref->file())) {
|
||||
TagLib::ASF::Tag *tag = file_asf->tag();
|
||||
if (tag) {
|
||||
SetASFTag(tag, song);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle all the files which have VorbisComments (Ogg, OPUS, ...) in the same way;
|
||||
// apart, so we keep specific behavior for some formats by adding another "else if" block above.
|
||||
if (!is_flac) {
|
||||
if (TagLib::Ogg::XiphComment *vorbis_comment = dynamic_cast<TagLib::Ogg::XiphComment*>(fileref->file()->tag())) {
|
||||
if (vorbis_comment) {
|
||||
if (save_tags) {
|
||||
SetVorbisComments(vorbis_comment, song);
|
||||
}
|
||||
if (save_playcount) {
|
||||
SetPlaycount(vorbis_comment, song.playcount());
|
||||
}
|
||||
if (save_rating) {
|
||||
SetRating(vorbis_comment, song.rating());
|
||||
}
|
||||
if (save_cover) {
|
||||
SetEmbeddedCover(vorbis_comment, cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Use SaveFileWithFallback which tries direct save first, then atomic write workaround
|
||||
const bool success = SaveFileWithFallback(filename, apply_modifications);
|
||||
// For MPEG files, use save with ID3v2 version parameter
|
||||
if (TagLib::MPEG::File *file_mpeg = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
|
||||
success = file_mpeg->save(TagLib::MPEG::File::AllTags, TagLib::File::StripOthers, taglib_id3v2_version);
|
||||
}
|
||||
// For WAV files with ID3v2 tags
|
||||
else if (TagLib::RIFF::WAV::File *file_wav = dynamic_cast<TagLib::RIFF::WAV::File*>(fileref->file())) {
|
||||
success = file_wav->save(TagLib::RIFF::WAV::File::AllTags, TagLib::File::StripOthers, taglib_id3v2_version);
|
||||
}
|
||||
// For AIFF files with ID3v2 tags
|
||||
else if (TagLib::RIFF::AIFF::File *file_aiff = dynamic_cast<TagLib::RIFF::AIFF::File*>(fileref->file())) {
|
||||
success = file_aiff->save(taglib_id3v2_version);
|
||||
}
|
||||
// For all other file types, use default save
|
||||
else {
|
||||
success = fileref->save();
|
||||
}
|
||||
|
||||
#ifdef Q_OS_LINUX
|
||||
if (success) {
|
||||
@@ -1819,69 +1753,64 @@ TagReaderResult TagReaderTagLib::SaveEmbeddedCover(const QString &filename, cons
|
||||
return TagReaderResult::ErrorCode::FileDoesNotExist;
|
||||
}
|
||||
|
||||
ScopedPtr<TagLib::FileRef> fileref(factory_->GetFileRef(filename));
|
||||
if (!fileref || fileref->isNull()) {
|
||||
qLog(Error) << "TagLib could not open file" << filename;
|
||||
return TagReaderResult::ErrorCode::FileOpenError;
|
||||
}
|
||||
|
||||
const AlbumCoverTagData cover = LoadAlbumCoverTagData(filename, save_tag_cover_data);
|
||||
|
||||
// Lambda function that applies cover modifications to a FileRef
|
||||
auto apply_modifications = [&](TagLib::FileRef *fileref) -> bool {
|
||||
if (!fileref || fileref->isNull()) {
|
||||
return false;
|
||||
// FLAC
|
||||
if (TagLib::FLAC::File *file_flac = dynamic_cast<TagLib::FLAC::File*>(fileref->file())) {
|
||||
TagLib::Ogg::XiphComment *vorbis_comment = file_flac->xiphComment(true);
|
||||
if (vorbis_comment) {
|
||||
SetEmbeddedCover(file_flac, vorbis_comment, cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
|
||||
// FLAC
|
||||
if (TagLib::FLAC::File *file_flac = dynamic_cast<TagLib::FLAC::File*>(fileref->file())) {
|
||||
TagLib::Ogg::XiphComment *vorbis_comment = file_flac->xiphComment(true);
|
||||
if (vorbis_comment) {
|
||||
SetEmbeddedCover(file_flac, vorbis_comment, cover.data, cover.mimetype);
|
||||
}
|
||||
// Ogg Vorbis / Opus / Speex
|
||||
else if (TagLib::Ogg::XiphComment *vorbis_comment = dynamic_cast<TagLib::Ogg::XiphComment*>(fileref->file()->tag())) {
|
||||
SetEmbeddedCover(vorbis_comment, cover.data, cover.mimetype);
|
||||
}
|
||||
|
||||
// MP3
|
||||
else if (TagLib::MPEG::File *file_mp3 = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
|
||||
TagLib::ID3v2::Tag *tag = file_mp3->ID3v2Tag();
|
||||
if (tag) {
|
||||
SetEmbeddedCover(tag, cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
|
||||
// Ogg Vorbis / Opus / Speex
|
||||
else if (TagLib::Ogg::XiphComment *vorbis_comment = dynamic_cast<TagLib::Ogg::XiphComment*>(fileref->file()->tag())) {
|
||||
SetEmbeddedCover(vorbis_comment, cover.data, cover.mimetype);
|
||||
// MP4/AAC
|
||||
else if (TagLib::MP4::File *file_aac = dynamic_cast<TagLib::MP4::File*>(fileref->file())) {
|
||||
TagLib::MP4::Tag *tag = file_aac->tag();
|
||||
if (tag) {
|
||||
SetEmbeddedCover(file_aac, tag, cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
|
||||
// MP3
|
||||
else if (TagLib::MPEG::File *file_mp3 = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
|
||||
TagLib::ID3v2::Tag *tag = file_mp3->ID3v2Tag();
|
||||
if (tag) {
|
||||
SetEmbeddedCover(tag, cover.data, cover.mimetype);
|
||||
}
|
||||
// WAV
|
||||
else if (TagLib::RIFF::WAV::File *file_wav = dynamic_cast<TagLib::RIFF::WAV::File*>(fileref->file())) {
|
||||
if (file_wav->ID3v2Tag()) {
|
||||
SetEmbeddedCover(file_wav->ID3v2Tag(), cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
|
||||
// MP4/AAC
|
||||
else if (TagLib::MP4::File *file_aac = dynamic_cast<TagLib::MP4::File*>(fileref->file())) {
|
||||
TagLib::MP4::Tag *tag = file_aac->tag();
|
||||
if (tag) {
|
||||
SetEmbeddedCover(file_aac, tag, cover.data, cover.mimetype);
|
||||
}
|
||||
// AIFF
|
||||
else if (TagLib::RIFF::AIFF::File *file_aiff = dynamic_cast<TagLib::RIFF::AIFF::File*>(fileref->file())) {
|
||||
if (file_aiff->tag()) {
|
||||
SetEmbeddedCover(file_aiff->tag(), cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
|
||||
// WAV
|
||||
else if (TagLib::RIFF::WAV::File *file_wav = dynamic_cast<TagLib::RIFF::WAV::File*>(fileref->file())) {
|
||||
if (file_wav->ID3v2Tag()) {
|
||||
SetEmbeddedCover(file_wav->ID3v2Tag(), cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
|
||||
// AIFF
|
||||
else if (TagLib::RIFF::AIFF::File *file_aiff = dynamic_cast<TagLib::RIFF::AIFF::File*>(fileref->file())) {
|
||||
if (file_aiff->tag()) {
|
||||
SetEmbeddedCover(file_aiff->tag(), cover.data, cover.mimetype);
|
||||
}
|
||||
}
|
||||
|
||||
// Not supported.
|
||||
else {
|
||||
qLog(Error) << "Saving embedded art is not supported for %1" << filename;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Use SaveFileWithFallback which tries direct save first, then atomic write workaround
|
||||
const bool success = SaveFileWithFallback(filename, apply_modifications);
|
||||
// Not supported.
|
||||
else {
|
||||
qLog(Error) << "Saving embedded art is not supported for %1" << filename;
|
||||
return TagReaderResult::ErrorCode::Unsupported;
|
||||
}
|
||||
|
||||
const bool success = fileref->file()->save();
|
||||
#ifdef Q_OS_LINUX
|
||||
if (success) {
|
||||
// Linux: inotify doesn't seem to notice the change to the file unless we change the timestamps as well. (this is what touch does)
|
||||
@@ -1978,69 +1907,64 @@ TagReaderResult TagReaderTagLib::SaveSongPlaycount(const QString &filename, cons
|
||||
return TagReaderResult::ErrorCode::FileDoesNotExist;
|
||||
}
|
||||
|
||||
// Lambda function that applies playcount modifications to a FileRef
|
||||
auto apply_modifications = [&](TagLib::FileRef *fileref) -> bool {
|
||||
if (!fileref || fileref->isNull()) {
|
||||
return false;
|
||||
}
|
||||
ScopedPtr<TagLib::FileRef> fileref(factory_->GetFileRef(filename));
|
||||
if (!fileref || fileref->isNull()) {
|
||||
qLog(Error) << "TagLib could not open file" << filename;
|
||||
return TagReaderResult::ErrorCode::FileOpenError;
|
||||
}
|
||||
|
||||
if (TagLib::FLAC::File *flac_file = dynamic_cast<TagLib::FLAC::File*>(fileref->file())) {
|
||||
TagLib::Ogg::XiphComment *vorbis_comment = flac_file->xiphComment(true);
|
||||
if (vorbis_comment) {
|
||||
SetPlaycount(vorbis_comment, playcount);
|
||||
}
|
||||
if (TagLib::FLAC::File *flac_file = dynamic_cast<TagLib::FLAC::File*>(fileref->file())) {
|
||||
TagLib::Ogg::XiphComment *vorbis_comment = flac_file->xiphComment(true);
|
||||
if (vorbis_comment) {
|
||||
SetPlaycount(vorbis_comment, playcount);
|
||||
}
|
||||
else if (TagLib::WavPack::File *wavpack_file = dynamic_cast<TagLib::WavPack::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = wavpack_file->APETag(true);
|
||||
if (tag) {
|
||||
SetPlaycount(tag, playcount);
|
||||
}
|
||||
}
|
||||
else if (TagLib::WavPack::File *wavpack_file = dynamic_cast<TagLib::WavPack::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = wavpack_file->APETag(true);
|
||||
if (tag) {
|
||||
SetPlaycount(tag, playcount);
|
||||
}
|
||||
else if (TagLib::APE::File *ape_file = dynamic_cast<TagLib::APE::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = ape_file->APETag(true);
|
||||
if (tag) {
|
||||
SetPlaycount(tag, playcount);
|
||||
}
|
||||
}
|
||||
else if (TagLib::APE::File *ape_file = dynamic_cast<TagLib::APE::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = ape_file->APETag(true);
|
||||
if (tag) {
|
||||
SetPlaycount(tag, playcount);
|
||||
}
|
||||
else if (TagLib::Ogg::XiphComment *vorbis_comment = dynamic_cast<TagLib::Ogg::XiphComment*>(fileref->file()->tag())) {
|
||||
if (vorbis_comment) {
|
||||
SetPlaycount(vorbis_comment, playcount);
|
||||
}
|
||||
}
|
||||
else if (TagLib::Ogg::XiphComment *vorbis_comment = dynamic_cast<TagLib::Ogg::XiphComment*>(fileref->file()->tag())) {
|
||||
if (vorbis_comment) {
|
||||
SetPlaycount(vorbis_comment, playcount);
|
||||
}
|
||||
else if (TagLib::MPEG::File *mpeg_file = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
|
||||
TagLib::ID3v2::Tag *tag = mpeg_file->ID3v2Tag(true);
|
||||
if (tag) {
|
||||
SetPlaycount(tag, playcount);
|
||||
}
|
||||
}
|
||||
else if (TagLib::MPEG::File *mpeg_file = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
|
||||
TagLib::ID3v2::Tag *tag = mpeg_file->ID3v2Tag(true);
|
||||
if (tag) {
|
||||
SetPlaycount(tag, playcount);
|
||||
}
|
||||
else if (TagLib::MP4::File *mp4_file = dynamic_cast<TagLib::MP4::File*>(fileref->file())) {
|
||||
TagLib::MP4::Tag *tag = mp4_file->tag();
|
||||
if (tag) {
|
||||
SetPlaycount(tag, playcount);
|
||||
}
|
||||
}
|
||||
else if (TagLib::MP4::File *mp4_file = dynamic_cast<TagLib::MP4::File*>(fileref->file())) {
|
||||
TagLib::MP4::Tag *tag = mp4_file->tag();
|
||||
if (tag) {
|
||||
SetPlaycount(tag, playcount);
|
||||
}
|
||||
else if (TagLib::MPC::File *mpc_file = dynamic_cast<TagLib::MPC::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = mpc_file->APETag(true);
|
||||
if (tag) {
|
||||
SetPlaycount(tag, playcount);
|
||||
}
|
||||
}
|
||||
else if (TagLib::MPC::File *mpc_file = dynamic_cast<TagLib::MPC::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = mpc_file->APETag(true);
|
||||
if (tag) {
|
||||
SetPlaycount(tag, playcount);
|
||||
}
|
||||
else if (TagLib::ASF::File *asf_file = dynamic_cast<TagLib::ASF::File*>(fileref->file())) {
|
||||
TagLib::ASF::Tag *tag = asf_file->tag();
|
||||
if (tag && playcount > 0) {
|
||||
tag->addAttribute(kASF_FMPS_Playcount, TagLib::ASF::Attribute(QStringToTagLibString(QString::number(playcount))));
|
||||
}
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
}
|
||||
else if (TagLib::ASF::File *asf_file = dynamic_cast<TagLib::ASF::File*>(fileref->file())) {
|
||||
TagLib::ASF::Tag *tag = asf_file->tag();
|
||||
if (tag && playcount > 0) {
|
||||
tag->addAttribute(kASF_FMPS_Playcount, TagLib::ASF::Attribute(QStringToTagLibString(QString::number(playcount))));
|
||||
}
|
||||
}
|
||||
else {
|
||||
return TagReaderResult::ErrorCode::Unsupported;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Use SaveFileWithFallback which tries direct save first, then atomic write workaround
|
||||
const bool success = SaveFileWithFallback(filename, apply_modifications);
|
||||
|
||||
const bool success = fileref->save();
|
||||
#ifdef Q_OS_LINUX
|
||||
if (success) {
|
||||
// Linux: inotify doesn't seem to notice the change to the file unless we change the timestamps as well. (this is what touch does)
|
||||
@@ -2113,68 +2037,63 @@ TagReaderResult TagReaderTagLib::SaveSongRating(const QString &filename, const f
|
||||
return TagReaderResult::ErrorCode::Success;
|
||||
}
|
||||
|
||||
// Lambda function that applies rating modifications to a FileRef
|
||||
auto apply_modifications = [&](TagLib::FileRef *fileref) -> bool {
|
||||
if (!fileref || fileref->isNull()) {
|
||||
return false;
|
||||
}
|
||||
ScopedPtr<TagLib::FileRef> fileref(factory_->GetFileRef(filename));
|
||||
if (!fileref || fileref->isNull()) {
|
||||
qLog(Error) << "TagLib could not open file" << filename;
|
||||
return TagReaderResult::ErrorCode::FileOpenError;
|
||||
}
|
||||
|
||||
if (TagLib::FLAC::File *flac_file = dynamic_cast<TagLib::FLAC::File*>(fileref->file())) {
|
||||
TagLib::Ogg::XiphComment *vorbis_comment = flac_file->xiphComment(true);
|
||||
if (vorbis_comment) {
|
||||
SetRating(vorbis_comment, rating);
|
||||
}
|
||||
}
|
||||
else if (TagLib::WavPack::File *wavpack_file = dynamic_cast<TagLib::WavPack::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = wavpack_file->APETag(true);
|
||||
if (tag) {
|
||||
SetRating(tag, rating);
|
||||
}
|
||||
}
|
||||
else if (TagLib::APE::File *ape_file = dynamic_cast<TagLib::APE::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = ape_file->APETag(true);
|
||||
if (tag) {
|
||||
SetRating(tag, rating);
|
||||
}
|
||||
}
|
||||
else if (TagLib::Ogg::XiphComment *vorbis_comment = dynamic_cast<TagLib::Ogg::XiphComment*>(fileref->file()->tag())) {
|
||||
if (TagLib::FLAC::File *flac_file = dynamic_cast<TagLib::FLAC::File*>(fileref->file())) {
|
||||
TagLib::Ogg::XiphComment *vorbis_comment = flac_file->xiphComment(true);
|
||||
if (vorbis_comment) {
|
||||
SetRating(vorbis_comment, rating);
|
||||
}
|
||||
else if (TagLib::MPEG::File *mpeg_file = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
|
||||
TagLib::ID3v2::Tag *tag = mpeg_file->ID3v2Tag(true);
|
||||
if (tag) {
|
||||
SetRating(tag, rating);
|
||||
}
|
||||
}
|
||||
else if (TagLib::WavPack::File *wavpack_file = dynamic_cast<TagLib::WavPack::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = wavpack_file->APETag(true);
|
||||
if (tag) {
|
||||
SetRating(tag, rating);
|
||||
}
|
||||
else if (TagLib::MP4::File *mp4_file = dynamic_cast<TagLib::MP4::File*>(fileref->file())) {
|
||||
TagLib::MP4::Tag *tag = mp4_file->tag();
|
||||
if (tag) {
|
||||
SetRating(tag, rating);
|
||||
}
|
||||
}
|
||||
else if (TagLib::APE::File *ape_file = dynamic_cast<TagLib::APE::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = ape_file->APETag(true);
|
||||
if (tag) {
|
||||
SetRating(tag, rating);
|
||||
}
|
||||
else if (TagLib::ASF::File *asf_file = dynamic_cast<TagLib::ASF::File*>(fileref->file())) {
|
||||
TagLib::ASF::Tag *tag = asf_file->tag();
|
||||
if (tag) {
|
||||
SetRating(tag, rating);
|
||||
}
|
||||
}
|
||||
else if (TagLib::Ogg::XiphComment *vorbis_comment = dynamic_cast<TagLib::Ogg::XiphComment*>(fileref->file()->tag())) {
|
||||
SetRating(vorbis_comment, rating);
|
||||
}
|
||||
else if (TagLib::MPEG::File *mpeg_file = dynamic_cast<TagLib::MPEG::File*>(fileref->file())) {
|
||||
TagLib::ID3v2::Tag *tag = mpeg_file->ID3v2Tag(true);
|
||||
if (tag) {
|
||||
SetRating(tag, rating);
|
||||
}
|
||||
else if (TagLib::MPC::File *mpc_file = dynamic_cast<TagLib::MPC::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = mpc_file->APETag(true);
|
||||
if (tag) {
|
||||
SetRating(tag, rating);
|
||||
}
|
||||
}
|
||||
else if (TagLib::MP4::File *mp4_file = dynamic_cast<TagLib::MP4::File*>(fileref->file())) {
|
||||
TagLib::MP4::Tag *tag = mp4_file->tag();
|
||||
if (tag) {
|
||||
SetRating(tag, rating);
|
||||
}
|
||||
else {
|
||||
qLog(Error) << "Unsupported file for saving rating for" << filename;
|
||||
return false;
|
||||
}
|
||||
else if (TagLib::ASF::File *asf_file = dynamic_cast<TagLib::ASF::File*>(fileref->file())) {
|
||||
TagLib::ASF::Tag *tag = asf_file->tag();
|
||||
if (tag) {
|
||||
SetRating(tag, rating);
|
||||
}
|
||||
}
|
||||
else if (TagLib::MPC::File *mpc_file = dynamic_cast<TagLib::MPC::File*>(fileref->file())) {
|
||||
TagLib::APE::Tag *tag = mpc_file->APETag(true);
|
||||
if (tag) {
|
||||
SetRating(tag, rating);
|
||||
}
|
||||
}
|
||||
else {
|
||||
qLog(Error) << "Unsupported file for saving rating for" << filename;
|
||||
return TagReaderResult::ErrorCode::Unsupported;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Use SaveFileWithFallback which tries direct save first, then atomic write workaround
|
||||
const bool success = SaveFileWithFallback(filename, apply_modifications);
|
||||
|
||||
const bool success = fileref->save();
|
||||
#ifdef Q_OS_LINUX
|
||||
if (success) {
|
||||
// Linux: inotify doesn't seem to notice the change to the file unless we change the timestamps as well. (this is what touch does)
|
||||
|
||||
@@ -23,8 +23,6 @@
|
||||
|
||||
#include "config.h"
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
|
||||
@@ -48,6 +46,7 @@
|
||||
|
||||
#include "tagreaderbase.h"
|
||||
#include "savetagcoverdata.h"
|
||||
#include "tagid3v2version.h"
|
||||
|
||||
#undef TStringToQString
|
||||
#undef QStringToTString
|
||||
@@ -74,7 +73,7 @@ class TagReaderTagLib : public TagReaderBase {
|
||||
TagReaderResult ReadStream(const QUrl &url, const QString &filename, const quint64 size, const quint64 mtime, const QString &token_type, const QString &access_token, Song *song) const override;
|
||||
#endif
|
||||
|
||||
TagReaderResult WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data) const override;
|
||||
TagReaderResult WriteFile(const QString &filename, const Song &song, const SaveTagsOptions save_tags_options, const SaveTagCoverData &save_tag_cover_data, const TagID3v2Version tag_id3v2_version) const override;
|
||||
|
||||
TagReaderResult LoadEmbeddedCover(const QString &filename, QByteArray &data) const override;
|
||||
TagReaderResult SaveEmbeddedCover(const QString &filename, const SaveTagCoverData &save_tag_cover_data) const override;
|
||||
@@ -128,8 +127,6 @@ class TagReaderTagLib : public TagReaderBase {
|
||||
|
||||
static TagLib::String TagLibStringListToSlashSeparatedString(const TagLib::StringList &taglib_string_list, const uint begin_index = 0);
|
||||
|
||||
bool SaveFileWithFallback(const QString &filename, const std::function<bool(TagLib::FileRef*)> &save_function) const;
|
||||
|
||||
private:
|
||||
FileRefFactory *factory_;
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
#include "tagreaderrequest.h"
|
||||
#include "savetagsoptions.h"
|
||||
#include "savetagcoverdata.h"
|
||||
#include "tagid3v2version.h"
|
||||
|
||||
using std::make_shared;
|
||||
|
||||
@@ -39,6 +40,7 @@ class TagReaderWriteFileRequest : public TagReaderRequest {
|
||||
SaveTagsOptions save_tags_options;
|
||||
Song song;
|
||||
SaveTagCoverData save_tag_cover_data;
|
||||
TagID3v2Version tag_id3v2_version;
|
||||
};
|
||||
|
||||
using TagReaderWriteFileRequestPtr = SharedPtr<TagReaderWriteFileRequest>;
|
||||
|
||||
Reference in New Issue
Block a user