diff --git a/README.md b/README.md index 0d889fc2..7318abd0 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,11 @@ The Subsonic API is enabled by default. __Note__: since _LMS_ stores hashed and salted passwords, it cannot handle the __token authentication__ method defined from version 1.13.0. +## Keyboard shortcuts +* Play/pause: Space +* Previous track: Ctrl + Left +* Next track: Ctrl + Right + ## Installation ### Docker diff --git a/approot/admin-database.xml b/approot/admin-database.xml index 4e01c81b..91e9347b 100644 --- a/approot/admin-database.xml +++ b/approot/admin-database.xml @@ -60,7 +60,7 @@
- ${apply-btn class="btn-primary"} ${discard-btn} ${immediate-scan-btn class="btn-info"} + ${apply-btn class="btn-primary"} ${discard-btn}
@@ -69,42 +69,7 @@
-
-
${tr:Lms.Admin.Database.Status.status}
-
- ${status} -
-
-
-
- - - -
-
- -
-
- ${status} -
-
-
-
- -
-
- ${last-scan} -
-
-
-
-
- ${btn-report class="btn"} -
+ ${scanner-controller}
diff --git a/approot/admin-scannercontroller.xml b/approot/admin-scannercontroller.xml new file mode 100644 index 00000000..484fc64a --- /dev/null +++ b/approot/admin-scannercontroller.xml @@ -0,0 +1,45 @@ + + + + +
+
${tr:Lms.Admin.ScannerController.scanner}
+
+
+
+ +
+
+ ${status} +
${step-status}
+
+
+
+
+ +
+
+ ${last-scan} +
+
+
+
+
+ ${btn-report class="btn btn-xs"} +
+
+
+
+ ${btn-action} +
+
+
+
+
+
+ +
diff --git a/approot/artist.xml b/approot/artist.xml index 8534eb07..3e7bca0a 100644 --- a/approot/artist.xml +++ b/approot/artist.xml @@ -6,18 +6,20 @@

${name}

${clusters} - ${play-btn}${add-btn} + ${play-btn}${more-btn}
${releases class="Lms-explore-artist-entries-container"}
+ ${}

${tr:Lms.Explore.Artist.similar-artists}

${similar-artists}
+ ${
} ${}
diff --git a/approot/artists.xml b/approot/artists.xml index b5ed55b3..83d44747 100644 --- a/approot/artists.xml +++ b/approot/artists.xml @@ -13,9 +13,7 @@
${artists} -
- ${show-more class="btn-primary Lms-show-more"} -
+ ${loading-indicator class="Lms-horizontal-center Lms-loading-indicator"} diff --git a/approot/explore.xml b/approot/explore.xml index 2cd86c7c..80881197 100644 --- a/approot/explore.xml +++ b/approot/explore.xml @@ -5,7 +5,7 @@ ${contents class="Lms-explore-contents"} - + diff --git a/approot/messages.xml b/approot/messages.xml index 45e0280f..146afb5e 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -9,6 +9,7 @@ Discard Cancel Create +Loading... Login Logout Not a directory @@ -53,22 +54,29 @@ Update start time Weekly -Cannot get track duration -Cannot parse file -Cannot read file -{1} duplicate files: -{1} errors: -Get report -Last scan -Not available -Scanned {1} files in {2} on {3} ({4} errors, {5} duplicates) -No audio track -Duplicated file hash -Duplicated MBID -Status -Not scheduled -Scheduled on {1} -Scanning {1} of {2} files ({3} %) +Cannot get track duration +Cannot parse file +Cannot read file +{1} duplicate files: +{1} errors: +Force scan now +Get report +Last scan +Not available +Scanned {1} files in {2} on {3} ({4} errors, {5} duplicates) +No audio track +Duplicated file hash +Duplicated MBID +Scan now +Scanner +Status +Not scheduled +Scheduled on {1} +Scanning: step {1}/{2} +Checking for missing files... {1}% +Discovering files: {1} files +Fetching track features from AcousticBrainz: {1}/{2} tracks ({3}%)... +Scanning files: {1}/{2} files ({3}%)... New user @@ -113,11 +121,12 @@ Most played MusicBrainz Artist MusicBrainz Release +Play last +Play shuffled Random Recently added Recently played Albums -Show more Tracks Type Value @@ -150,11 +159,8 @@ Added {1} track Added {1} tracks - - Playing {1} track - Playing {1} tracks - Play Queue +Play Queue full! Radio mode Repeat Shuffle diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index e9dc699a..11dc324e 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -9,6 +9,7 @@ Annuler Annuler Créer +Chargement... Login Quitter N'est pas un répertoire @@ -53,22 +54,29 @@ Heure de départ de la mise à jour Toutes les semaines -Impossible de récupérer la durée de la piste -Impossible d'analyser le fichier -Impossible de lire le fichier -{1} fichiers dupliqués : -{1} erreurs : -Rapport -Dernier scan -Non disponible -{1} fichiers scannés en {2} le {3} ({4} erreurs, {5} duplicatas) -Pas de piste audio -Hash dupliqué -MBID dupliqué -Statut -Non planifié -Planifié le {1} -Scan de {1} fichiers sur {2} ({3} %) +Impossible de récupérer la durée de la piste +Impossible d'analyser le fichier +Impossible de lire le fichier +{1} fichiers dupliqués : +{1} erreurs : +Lancer un scan forcé +Rapport +Dernier scan +Non disponible +{1} fichiers scannés en {2} le {3} ({4} erreurs, {5} duplicatas) +Pas de piste audio +Hash dupliqué +MBID dupliqué +Lancer un scan +Scanner +Statut +Non planifié +Planifié le {1} +En cours de scan : étape {1}/{2} +Vérification des fichiers supprimés... {1}% +Découverte des fichiers: {1} fichiers +Récupération des métadonnées AcousticBrainz: {1}/{2} fichiers ({3}%)... +Scan des fichiers: {1}/{2} fichiers ({3}%)... Ajouter @@ -113,11 +121,12 @@ Plus joués MusicBrainz Artist MusicBrainz Release +Jouer en dernier +Jouer aléatoirement Aléatoire Ajoutés récemment Joués récemment Albums -Voir plus Pistes Type Valeur @@ -132,7 +141,7 @@ Artistes d'album -Albums similaires +Albums similaires Copyright Disque {1} @@ -150,11 +159,8 @@ {1} piste ajoutée {1} pistes ajoutées - - {1} piste ajoutée - {1} pistes ajoutées - Liste de lecture +Liste de lecture pleine! Mode radio Repéter Mélanger diff --git a/approot/playqueue.xml b/approot/playqueue.xml index e4e8ebcd..3fb28e46 100644 --- a/approot/playqueue.xml +++ b/approot/playqueue.xml @@ -16,9 +16,7 @@

${nb-tracks}

${entries} -
- ${show-more class="btn-primary Lms-show-more"} -
+ ${loading-indicator class="Lms-horizontal-center Lms-loading-indicator"} diff --git a/approot/release.xml b/approot/release.xml index 86e6d9c2..cb49ffd7 100644 --- a/approot/release.xml +++ b/approot/release.xml @@ -17,12 +17,13 @@ ${}

${artist class="Lms-artistname text-muted"}

${
}
${}${year}${}${} (${orig-year})${}
${clusters} - ${play-btn}${add-btn} + ${play-btn}${more-btn} ${container}
+ ${}

${tr:Lms.Explore.Release.similar-releases}

@@ -31,6 +32,7 @@
+ ${} ${}
@@ -66,7 +68,7 @@
- ${play-btn}${add-btn} + ${play-btn}${more-btn}
${duration} diff --git a/approot/releases.xml b/approot/releases.xml index 3ccc42f5..430e48dd 100644 --- a/approot/releases.xml +++ b/approot/releases.xml @@ -6,14 +6,12 @@

${tr:Lms.Explore.releases}

${mode class="nav nav-pills Lms-explore-mode-container"} - ${play-btn}${add-btn} + ${play-btn}${more-btn}
${releases}
-
- ${show-more class="btn-primary Lms-show-more"} -
+ ${loading-indicator class="Lms-horizontal-center Lms-loading-indicator"} diff --git a/approot/templates.xml b/approot/templates.xml index 78d826d1..2145b7f7 100644 --- a/approot/templates.xml +++ b/approot/templates.xml @@ -56,4 +56,9 @@ ${player class="Lms-player"} + + + ${tr:Lms.loading} + + diff --git a/approot/tracks.xml b/approot/tracks.xml index fe402479..4c34bab7 100644 --- a/approot/tracks.xml +++ b/approot/tracks.xml @@ -9,12 +9,10 @@

${tr:Lms.Explore.tracks}

${mode class="nav nav-pills Lms-explore-mode-container"} - ${play-btn}${add-btn} + ${play-btn}${more-btn}
${tracks} -
- ${show-more class="btn-primary Lms-show-more"} -
+ ${loading-indicator class="Lms-horizontal-center Lms-loading-indicator"}
@@ -35,7 +33,7 @@
- ${play-btn}${add-btn} + ${play-btn}${more-btn}
${duration} diff --git a/docroot/css/lms.css b/docroot/css/lms.css index 3fecc982..b47302bb 100644 --- a/docroot/css/lms.css +++ b/docroot/css/lms.css @@ -259,6 +259,11 @@ a.Lms-artistname:hover, a.Lms-artistname:focus { justify-content: center; } +.Lms-loading-indicator { + margin-top: 8px; + margin-bottom: 8px; +} + .Lms-navbar-search { max-width: 175px; padding-top: 7.5px; @@ -478,9 +483,4 @@ a.Lms-releasename:hover, a.Lms-releasename:focus { padding-bottom: 100%; } -.Lms-show-more { - margin-top: 8px; - margin-bottom: 8px; -} - diff --git a/docroot/js/mediaplayer.js b/docroot/js/mediaplayer.js index a5c553f1..776b5b0a 100644 --- a/docroot/js/mediaplayer.js +++ b/docroot/js/mediaplayer.js @@ -65,6 +65,25 @@ LMS.mediaplayer = function () { } } + var _playPause = function() { + _audioCtx.resume(); + if (_elems.audio.paused && _elems.audio.children.length > 0) { + _playTrack(); + } + else + _elems.audio.pause(); + } + + var _playPrevious = function() { + _audioCtx.resume(); + _requestPreviousTrack(); + } + + var _playNext = function() { + _audioCtx.resume(); + _requestNextTrack(); + } + var _requestPreviousTrack = function() { Wt.emit(_root, "playPrevious"); } @@ -146,21 +165,14 @@ LMS.mediaplayer = function () { _gainNode.connect(_audioCtx.destination); _elems.playpause.addEventListener("click", function() { - _audioCtx.resume(); - if (_elems.audio.paused && _elems.audio.children.length > 0) { - _playTrack(); - } - else - _elems.audio.pause(); + _playPause(); }); _elems.previous.addEventListener("click", function() { - _audioCtx.resume(); - _requestPreviousTrack(); + _playPrevious(); }); _elems.next.addEventListener("click", function() { - _audioCtx.resume(); - _requestNextTrack(); + _playNext(); }); _elems.seek.addEventListener("change", function() { _audioCtx.resume(); @@ -225,6 +237,26 @@ LMS.mediaplayer = function () { } }); + document.addEventListener("keydown", function(event) { + let handled = false; + + if (event.keyCode == 32 && !(event.target instanceof HTMLInputElement)) { + _playPause(); + handled = true; + } + else if (event.ctrlKey && event.keyCode == 37) { + _playPrevious(); + handled = true; + } + else if (event.ctrlKey && event.keyCode == 39) { + _playNext(); + handled = true; + } + + if (handled) + event.preventDefault(); + }); + if ('mediaSession' in navigator) { navigator.mediaSession.setActionHandler("previoustrack", function() { _requestPreviousTrack(); diff --git a/src/libs/av/CMakeLists.txt b/src/libs/av/CMakeLists.txt index 30e72176..ce3fb126 100644 --- a/src/libs/av/CMakeLists.txt +++ b/src/libs/av/CMakeLists.txt @@ -6,16 +6,15 @@ add_library(lmsav SHARED impl/AvTypes.cpp ) -# TODO make av libs private target_include_directories(lmsav INTERFACE include - ${AVCODEC_INCLUDE_DIR} - ${AVFORMAT_INCLUDE_DIR} - ${AVUTIL_INCLUDE_DIR} ) target_include_directories(lmsav PRIVATE include/ + ${AVCODEC_INCLUDE_DIR} + ${AVFORMAT_INCLUDE_DIR} + ${AVUTIL_INCLUDE_DIR} ) target_link_libraries(lmsav PUBLIC @@ -24,6 +23,9 @@ target_link_libraries(lmsav PUBLIC avutil std::filesystem wt + ) + +target_link_libraries(lmsav PRIVATE ${AVFORMAT_LIBRARY} ${AVUTIL_LIBRARY} ) diff --git a/src/libs/av/impl/AvInfo.cpp b/src/libs/av/impl/AvInfo.cpp index ff4d010d..aca3c045 100644 --- a/src/libs/av/impl/AvInfo.cpp +++ b/src/libs/av/impl/AvInfo.cpp @@ -19,6 +19,14 @@ #include "av/AvInfo.hpp" +extern "C" +{ +#define __STDC_CONSTANT_MACROS +#include +#include +#include +} + #include #include "utils/Logger.hpp" diff --git a/src/libs/av/impl/AvTranscodeResourceHandler.hpp b/src/libs/av/impl/AvTranscodeResourceHandler.hpp index 351e6e45..543ca74b 100644 --- a/src/libs/av/impl/AvTranscodeResourceHandler.hpp +++ b/src/libs/av/impl/AvTranscodeResourceHandler.hpp @@ -39,7 +39,6 @@ namespace Av static constexpr std::size_t _chunkSize {262144}; const std::filesystem::path _trackPath; Transcoder _transcoder; - }; } diff --git a/src/libs/av/impl/AvTranscoder.cpp b/src/libs/av/impl/AvTranscoder.cpp index 47454837..f1b9ed94 100644 --- a/src/libs/av/impl/AvTranscoder.cpp +++ b/src/libs/av/impl/AvTranscoder.cpp @@ -1,5 +1,5 @@ /* - + * Copyright (C) 2020 Emeric Poupon * * This file is part of LMS. * @@ -59,17 +59,20 @@ Transcoder::start() if (!std::filesystem::exists(_filePath)) { LOG(ERROR) << "File '" << _filePath << "' does not exist!"; + _isComplete = true; return false; } else if (!std::filesystem::is_regular_file( _filePath) ) { LOG(ERROR) << "File '" << _filePath << "' is not regular!"; + _isComplete = true; return false; } } catch (const std::filesystem::filesystem_error& e) { LOG(ERROR) << "File error on '" << _filePath.string() << "': " << e.what(); + _isComplete = true; return false; } @@ -154,6 +157,7 @@ Transcoder::start() break; default: + _isComplete = true; return false; } @@ -178,12 +182,14 @@ Transcoder::start() if (!_child->is_open()) { LOG(DEBUG) << "Exec failed!"; + _isComplete = true; return false; } if (_child->out().eof()) { LOG(DEBUG) << "Early end of file!"; + _isComplete = true; return false; } @@ -199,16 +205,6 @@ Transcoder::process(std::vector& output, std::size_t maxSize) if (!_child || _isComplete) return; - if (_child->out().fail()) - { - LOG(DEBUG) << "Stdout FAILED 2"; - } - - if (_child->out().eof()) - { - LOG(DEBUG) << "Stdout ENDED 2"; - } - output.resize(maxSize); //Read on the output stream @@ -218,14 +214,19 @@ Transcoder::process(std::vector& output, std::size_t maxSize) if (_child->out().fail()) { LOG(DEBUG) << "Stdout FAILED"; + _isComplete = true; } if (_child->out().eof()) { LOG(DEBUG) << "Stdout EOF!"; - _child->clear(); - _isComplete = true; + } + + if (_isComplete) + { + LOG(DEBUG) << "Transcode complete!"; + _child->clear(); _child.reset(); } diff --git a/src/libs/av/include/av/AvInfo.hpp b/src/libs/av/include/av/AvInfo.hpp index 96dc33f4..d719ae02 100644 --- a/src/libs/av/include/av/AvInfo.hpp +++ b/src/libs/av/include/av/AvInfo.hpp @@ -21,14 +21,6 @@ #pragma once -extern "C" -{ -#define __STDC_CONSTANT_MACROS -#include -#include -#include -} - #include #include #include @@ -38,6 +30,8 @@ extern "C" #include "AvTypes.hpp" +struct AVFormatContext; + namespace Av { diff --git a/src/libs/scanner/impl/MediaScanner.cpp b/src/libs/scanner/impl/MediaScanner.cpp index 0529ff4a..e463f518 100644 --- a/src/libs/scanner/impl/MediaScanner.cpp +++ b/src/libs/scanner/impl/MediaScanner.cpp @@ -257,22 +257,14 @@ MediaScanner::MediaScanner(Database::Db& db) } MediaScanner::~MediaScanner() -{ - if (_running) - stop(); -} - -void -MediaScanner::restart(void) { stop(); - start(); } void -MediaScanner::start(void) +MediaScanner::start() { - _running = true; + std::scoped_lock lock {_controlMutex}; scheduleNextScan(); @@ -280,27 +272,47 @@ MediaScanner::start(void) } void -MediaScanner::stop(void) +MediaScanner::stop() { - _running = false; + std::scoped_lock lock {_controlMutex}; + + _abortScan = true; _scheduleTimer.cancel(); - _ioService.stop(); } void -MediaScanner::requestImmediateScan() +MediaScanner::abortScan() { + LMS_LOG(DBUPDATER, DEBUG) << "Aborting scan..."; + std::scoped_lock lock {_controlMutex}; + + LMS_LOG(DBUPDATER, DEBUG) << "Waiting for the scan to abort..."; + + _abortScan = true; + _scheduleTimer.cancel(); + _ioService.stop(); + LMS_LOG(DBUPDATER, DEBUG) << "Scan abort done!"; + + _abortScan = false; + _ioService.start(); +} + +void +MediaScanner::requestImmediateScan(bool force) +{ + abortScan(); _ioService.post([=]() { - scheduleScan(); + scheduleScan(force); }); } void -MediaScanner::requestReschedule() +MediaScanner::requestReload() { + abortScan(); _ioService.post([=]() { scheduleNextScan(); @@ -308,7 +320,7 @@ MediaScanner::requestReschedule() } MediaScanner::Status -MediaScanner::getStatus() +MediaScanner::getStatus() const { Status res; @@ -317,7 +329,7 @@ MediaScanner::getStatus() res.currentState = _curState; res.nextScheduledScan = _nextScheduledScan; res.lastCompleteScanStats = _lastCompleteScanStats; - res.inProgressScanStats = _inProgressScanStats; + res.currentScanStepStats = _currentScanStepStats; return res; } @@ -365,7 +377,7 @@ MediaScanner::scheduleNextScan() if (nextScanDate.isValid()) { nextScanDateTime = Wt::WDateTime {nextScanDate, _startTime}; - scheduleScan(nextScanDateTime); + scheduleScan(false, nextScanDateTime); } { @@ -380,17 +392,21 @@ MediaScanner::scheduleNextScan() void MediaScanner::countAllFiles(ScanStats& stats) { - stats.filesToScan = 0; + ScanStepStats stepStats{stats.startTime, ScanProgressStep::DiscoveringFiles}; + + stats.filesScanned = 0; + notifyInProgress(stepStats); exploreFilesRecursive(_mediaDirectory, [&](std::error_code ec, const std::filesystem::path& path) { - if (!_running) + if (_abortScan) return false; if (!ec && isFileSupported(path, _fileExtensions)) { - stats.filesToScan++; - notifyInProgressIfNeeded(stats); + stats.filesScanned++; + stepStats.processedFiles++; + notifyInProgressIfNeeded(stepStats); } return true; @@ -398,13 +414,21 @@ MediaScanner::countAllFiles(ScanStats& stats) } void -MediaScanner::scheduleScan(const Wt::WDateTime& dateTime) +MediaScanner::scheduleScan(bool force, const Wt::WDateTime& dateTime) { + auto cb {[=](boost::system::error_code ec) + { + if (ec) + return; + + scan(force); + }}; + if (dateTime.isNull()) { LMS_LOG(DBUPDATER, INFO) << "Scheduling next scan right now"; - _scheduleTimer.expires_from_now(std::chrono::seconds(0)); - _scheduleTimer.async_wait(std::bind(&MediaScanner::scan, this, std::placeholders::_1)); + _scheduleTimer.expires_from_now(std::chrono::seconds {0}); + _scheduleTimer.async_wait(cb); } else { @@ -413,16 +437,13 @@ MediaScanner::scheduleScan(const Wt::WDateTime& dateTime) LMS_LOG(DBUPDATER, INFO) << "Scheduling next scan at " << std::string(std::ctime(&t)); _scheduleTimer.expires_at(timePoint); - _scheduleTimer.async_wait(std::bind(&MediaScanner::scan, this, std::placeholders::_1)); + _scheduleTimer.async_wait(cb); } } void -MediaScanner::scan(boost::system::error_code err) +MediaScanner::scan(bool forceScan) { - if (err) - return; - scanStarted().emit(); { @@ -438,13 +459,11 @@ MediaScanner::scan(boost::system::error_code err) refreshScanSettings(); - bool forceScan {false}; + removeMissingTracks(stats); LMS_LOG(DBUPDATER, DEBUG) << "Counting files in media directory '" << _mediaDirectory.string() << "'..."; countAllFiles(stats); - LMS_LOG(DBUPDATER, DEBUG) << "-> Nb files = " << stats.filesToScan; - - removeMissingTracks(stats); + LMS_LOG(DBUPDATER, DEBUG) << "-> Nb files = " << stats.filesScanned; LMS_LOG(UI, INFO) << "Checks complete, force scan = " << forceScan; @@ -454,36 +473,39 @@ MediaScanner::scan(boost::system::error_code err) removeOrphanEntries(); - if (_running) + if (!_abortScan) checkDuplicatedAudioFiles(stats); // Now update all the track features if needed fetchTrackFeatures(stats); - LMS_LOG(DBUPDATER, INFO) << "Scan " << (_running ? "complete" : "aborted") << ". Changes = " << stats.nbChanges() << " (added = " << stats.additions << ", removed = " << stats.deletions << ", updated = " << stats.updates << "), Not changed = " << stats.skips << ", Scanned = " << stats.scans << " (errors = " << stats.errors.size() << "), features fetched = " << stats.featuresFetched << "/" << stats.featuresToFetch <<", duplicates = " << stats.duplicates.size(); + LMS_LOG(DBUPDATER, INFO) << "Scan " << (_abortScan ? "aborted" : "complete") << ". Changes = " << stats.nbChanges() << " (added = " << stats.additions << ", removed = " << stats.deletions << ", updated = " << stats.updates << "), Not changed = " << stats.skips << ", Scanned = " << stats.scans << " (errors = " << stats.errors.size() << "), features fetched = " << stats.featuresFetched << ", duplicates = " << stats.duplicates.size(); _dbSession.optimize(); - if (_running) + if (!_abortScan) { stats.stopTime = Wt::WLocalDateTime::currentDateTime().toUTC(); { std::unique_lock lock {_statusMutex}; _lastCompleteScanStats = std::move(stats); - _inProgressScanStats.reset(); + _currentScanStepStats.reset(); } + LMS_LOG(DBUPDATER, DEBUG) << "Scan not aborted, scheduling next scan!"; scheduleNextScan(); scanComplete().emit(); } else { + LMS_LOG(DBUPDATER, DEBUG) << "Scan aborted, not scheduling next scan!"; + std::unique_lock lock {_statusMutex}; _curState = State::NotScheduled; - _inProgressScanStats.reset(); + _currentScanStepStats.reset(); } } @@ -519,6 +541,8 @@ MediaScanner::fetchTrackFeatures(ScanStats& stats) if (_recommendationEngineType != ScanSettings::RecommendationEngineType::Features) return; + ScanStepStats stepStats{stats.startTime, ScanProgressStep::FetchingTrackFeatures}; + LMS_LOG(DBUPDATER, INFO) << "Fetching missing track features..."; struct TrackInfo @@ -540,18 +564,21 @@ MediaScanner::fetchTrackFeatures(ScanStats& stats) return res; }()}; - stats.featuresToFetch = tracksToFetch.size(); + stepStats.filesToProcess = tracksToFetch.size(); + notifyInProgress(stepStats); LMS_LOG(DBUPDATER, INFO) << "Found " << tracksToFetch.size() << " track(s) to fetch!"; for (const TrackInfo& trackToFetch : tracksToFetch) { - if (!_running) + if (_abortScan) return; if (fetchTrackFeatures(trackToFetch.id, trackToFetch.mbid)) stats.featuresFetched++; + stepStats.processedFiles++; + notifyInProgressIfNeeded(stepStats); } LMS_LOG(DBUPDATER, INFO) << "Track features fetched!"; @@ -591,27 +618,25 @@ MediaScanner::refreshScanSettings() } void -MediaScanner::notifyInProgress(const ScanStats& stats) +MediaScanner::notifyInProgress(const ScanStepStats& stepStats) { - const ScanProgressStats progressStats {stats.toProgressStats()}; - { std::unique_lock lock {_statusMutex}; - _inProgressScanStats = progressStats; + _currentScanStepStats = stepStats; } const std::chrono::system_clock::time_point now {std::chrono::system_clock::now()}; - _sigScanInProgress(progressStats); + _sigScanInProgress(stepStats); _lastScanInProgressEmit = now; } void -MediaScanner::notifyInProgressIfNeeded(const ScanStats& stats) +MediaScanner::notifyInProgressIfNeeded(const ScanStepStats& stepStats) { std::chrono::system_clock::time_point now {std::chrono::system_clock::now()}; - if (std::chrono::duration_cast(now - _lastScanInProgressEmit).count() > 2) - notifyInProgress(stats); + if (std::chrono::duration_cast(now - _lastScanInProgressEmit).count() > 1) + notifyInProgress(stepStats); } void @@ -772,9 +797,13 @@ MediaScanner::scanAudioFile(const std::filesystem::path& file, bool forceScan, S void MediaScanner::scanMediaDirectory(const std::filesystem::path& mediaDirectory, bool forceScan, ScanStats& stats) { + ScanStepStats stepStats{stats.startTime, ScanProgressStep::ScanningFiles}; + stepStats.filesToProcess = stats.filesScanned; + notifyInProgress(stepStats); + exploreFilesRecursive(mediaDirectory, [&](std::error_code ec, const std::filesystem::path& path) { - if (!_running) + if (_abortScan) return false; if (ec) @@ -786,13 +815,12 @@ MediaScanner::scanMediaDirectory(const std::filesystem::path& mediaDirectory, bo { scanAudioFile(path, forceScan, stats ); - notifyInProgressIfNeeded(stats); + stepStats.processedFiles++; + notifyInProgressIfNeeded(stepStats); } return true; }); - - notifyInProgress(stats); } // Check if a file exists and is still in a media directory @@ -837,6 +865,8 @@ MediaScanner::removeMissingTracks(ScanStats& stats) { static constexpr std::size_t batchSize {50}; + ScanStepStats stepStats{stats.startTime, ScanProgressStep::ChekingForMissingFiles}; + LMS_LOG(DBUPDATER, DEBUG) << "Checking tracks to be removed..."; std::size_t trackCount {}; @@ -846,6 +876,9 @@ MediaScanner::removeMissingTracks(ScanStats& stats) } LMS_LOG(DBUPDATER, DEBUG) << trackCount << " tracks to be checked..."; + stepStats.filesToProcess = trackCount; + notifyInProgress(stepStats); + std::vector> trackPaths; std::vector tracksToRemove; @@ -861,11 +894,13 @@ MediaScanner::removeMissingTracks(ScanStats& stats) for (const auto& [trackId, trackPath] : trackPaths) { - if (!_running) + if (_abortScan) return; if (!checkFile(trackPath, _mediaDirectory, _fileExtensions)) tracksToRemove.push_back(trackId); + + stepStats.processedFiles++; } if (!tracksToRemove.empty()) @@ -883,7 +918,7 @@ MediaScanner::removeMissingTracks(ScanStats& stats) } } - notifyInProgressIfNeeded(stats); + notifyInProgressIfNeeded(stepStats); if (i == 0) break; diff --git a/src/libs/scanner/impl/MediaScanner.hpp b/src/libs/scanner/impl/MediaScanner.hpp index 8c19defb..0ff66730 100644 --- a/src/libs/scanner/impl/MediaScanner.hpp +++ b/src/libs/scanner/impl/MediaScanner.hpp @@ -52,26 +52,26 @@ class MediaScanner : public IMediaScanner void start() override; void stop() override; - void restart() override; + void requestReload() override; + void requestImmediateScan(bool force) override; - void requestImmediateScan() override; - void requestReschedule() override ; - - Status getStatus() override; + Status getStatus() const override; Wt::Signal<>& scanStarted() override { return _sigScanStarted; } Wt::Signal<>& scanComplete() override { return _sigScanComplete; } - Wt::Signal& scanInProgress() override { return _sigScanInProgress; } + Wt::Signal& scanInProgress() override { return _sigScanInProgress; } Wt::Signal& scheduled() override { return _sigScheduled; } private: // Job handling void scheduleNextScan(); - void scheduleScan(const Wt::WDateTime& dateTime = {}); + void scheduleScan(bool force, const Wt::WDateTime& dateTime = {}); + + void abortScan(); // Update database (scheduled callback) - void scan(boost::system::error_code ec); + void scan(bool force); void scanMediaDirectory( const std::filesystem::path& mediaDirectory, bool forceScan, ScanStats& stats); bool fetchTrackFeatures(Database::IdType trackId, const UUID& MBID); @@ -86,24 +86,25 @@ class MediaScanner : public IMediaScanner void checkDuplicatedAudioFiles(ScanStats& stats); void scanAudioFile(const std::filesystem::path& file, bool forceScan, ScanStats& stats); Database::IdType doScanAudioFile(const std::filesystem::path& file, ScanStats& stats); - void notifyInProgressIfNeeded(const ScanStats& stats); - void notifyInProgress(const ScanStats& stats); + void notifyInProgressIfNeeded(const ScanStepStats& stats); + void notifyInProgress(const ScanStepStats& stats); - bool _running {}; + std::mutex _controlMutex; + std::atomic _abortScan {}; Wt::WIOService _ioService; boost::asio::system_timer _scheduleTimer {_ioService}; Wt::Signal<> _sigScanStarted; Wt::Signal<> _sigScanComplete; - Wt::Signal _sigScanInProgress; + Wt::Signal _sigScanInProgress; std::chrono::system_clock::time_point _lastScanInProgressEmit {}; Wt::Signal _sigScheduled; Database::Session _dbSession; std::unique_ptr _metadataParser; - std::shared_mutex _statusMutex; + mutable std::shared_mutex _statusMutex; State _curState {State::NotScheduled}; std::optional _lastCompleteScanStats; - std::optional _inProgressScanStats; + std::optional _currentScanStepStats; Wt::WDateTime _nextScheduledScan; // Current scan settings diff --git a/src/libs/scanner/impl/MediaScannerStats.cpp b/src/libs/scanner/impl/MediaScannerStats.cpp index a2a984da..29af4235 100644 --- a/src/libs/scanner/impl/MediaScannerStats.cpp +++ b/src/libs/scanner/impl/MediaScannerStats.cpp @@ -40,16 +40,10 @@ ScanStats::nbChanges() const return additions + deletions + updates; } -ScanProgressStats -ScanStats::toProgressStats() const -{ - return ScanProgressStats {startTime, filesToScan, nbFiles()}; -} - unsigned -ScanProgressStats::progress() const +ScanStepStats::progress() const { - return (processedFiles / static_cast(filesToScan ? filesToScan : 1)) * 100; + return (processedFiles / static_cast(filesToProcess ? filesToProcess : 1)) * 100; } } // namespace Scanner diff --git a/src/libs/scanner/include/scanner/IMediaScanner.hpp b/src/libs/scanner/include/scanner/IMediaScanner.hpp index 1c0d3fdc..83e675fb 100644 --- a/src/libs/scanner/include/scanner/IMediaScanner.hpp +++ b/src/libs/scanner/include/scanner/IMediaScanner.hpp @@ -40,11 +40,10 @@ class IMediaScanner virtual void start() = 0; virtual void stop() = 0; - virtual void restart() = 0; // Async requests - virtual void requestImmediateScan() = 0; - virtual void requestReschedule() = 0; + virtual void requestReload() = 0; + virtual void requestImmediateScan(bool force) = 0; enum class State @@ -56,13 +55,13 @@ class IMediaScanner struct Status { - State currentState {State::NotScheduled}; - Wt::WDateTime nextScheduledScan; - std::optional lastCompleteScanStats; - std::optional inProgressScanStats; + State currentState {State::NotScheduled}; + Wt::WDateTime nextScheduledScan; + std::optional lastCompleteScanStats; + std::optional currentScanStepStats; }; - virtual Status getStatus() = 0; + virtual Status getStatus() const = 0; // Called just after scan start virtual Wt::Signal<>& scanStarted() = 0; @@ -71,7 +70,7 @@ class IMediaScanner virtual Wt::Signal<>& scanComplete() = 0; // Called during scan in progress - virtual Wt::Signal& scanInProgress() = 0; + virtual Wt::Signal& scanInProgress() = 0; // Called after a schedule virtual Wt::Signal& scheduled() = 0; diff --git a/src/libs/scanner/include/scanner/MediaScannerStats.hpp b/src/libs/scanner/include/scanner/MediaScannerStats.hpp index 99c898b6..f5eb67f7 100644 --- a/src/libs/scanner/include/scanner/MediaScannerStats.hpp +++ b/src/libs/scanner/include/scanner/MediaScannerStats.hpp @@ -56,15 +56,26 @@ namespace Scanner { }; + enum class ScanProgressStep : unsigned + { + ChekingForMissingFiles = 0, + DiscoveringFiles, + ScanningFiles, + FetchingTrackFeatures, + }; + static inline constexpr unsigned ScanProgressStepCount {4}; + // reduced scan stats - struct ScanProgressStats + struct ScanStepStats { Wt::WDateTime startTime; - std::size_t filesToScan {}; - std::size_t processedFiles {}; + ScanProgressStep currentStep; - unsigned progress() const; + std::size_t filesToProcess {}; + std::size_t processedFiles {}; + + unsigned progress() const; }; struct ScanStats @@ -72,25 +83,22 @@ namespace Scanner { Wt::WDateTime startTime; Wt::WDateTime stopTime; - std::size_t filesToScan {}; // Total number of files to be scanned (estimated) + std::size_t filesScanned {}; // Total number of files scanned (estimated) - std::size_t skips {}; // no change since last scan - std::size_t scans {}; // actually scanned filed + std::size_t skips {}; // no change since last scan + std::size_t scans {}; // actually scanned filed std::size_t additions {}; // added in DB std::size_t deletions {}; // removed from DB - std::size_t updates {}; // updated file in DB + std::size_t updates {}; // updated file in DB std::size_t featuresFetched {}; // features fetched in DB - std::size_t featuresToFetch {}; // features to be fetched in DB std::vector errors; std::vector duplicates; std::size_t nbFiles() const; std::size_t nbChanges() const; - - ScanProgressStats toProgressStats() const; }; } diff --git a/src/libs/subsonic/impl/Scan.cpp b/src/libs/subsonic/impl/Scan.cpp index 70b2deb0..b663aed2 100644 --- a/src/libs/subsonic/impl/Scan.cpp +++ b/src/libs/subsonic/impl/Scan.cpp @@ -35,8 +35,15 @@ namespace API::Subsonic::Scan const IMediaScanner::Status scanStatus {ServiceProvider::get()->getStatus()}; statusResponse.setAttribute("scanning", scanStatus.currentState == IMediaScanner::State::InProgress); - if (scanStatus.currentState == IMediaScanner::State::InProgress && scanStatus.inProgressScanStats) - statusResponse.setAttribute("count", scanStatus.inProgressScanStats->processedFiles); + if (scanStatus.currentState == IMediaScanner::State::InProgress) + { + std::size_t count{}; + + if (scanStatus.currentScanStepStats && scanStatus.currentScanStepStats->currentStep == ScanProgressStep::ScanningFiles) + count = scanStatus.currentScanStepStats->processedFiles; + + statusResponse.setAttribute("count", count); + } return statusResponse; } @@ -54,7 +61,7 @@ namespace API::Subsonic::Scan Response handleStartScan(RequestContext& context) { - ServiceProvider::get()->requestImmediateScan(); + ServiceProvider::get()->requestImmediateScan(false); Response response {Response::createOkResponse(context)}; response.addNode("scanStatus", createStatusResponseNode()); diff --git a/src/lms/CMakeLists.txt b/src/lms/CMakeLists.txt index 40fb59bf..88905053 100644 --- a/src/lms/CMakeLists.txt +++ b/src/lms/CMakeLists.txt @@ -10,11 +10,12 @@ add_executable(lms ui/SettingsView.cpp ui/TrackStringUtils.cpp ui/admin/DatabaseSettingsView.cpp - ui/admin/DatabaseStatus.cpp + ui/admin/ScannerController.cpp ui/admin/InitWizardView.cpp ui/admin/UserView.cpp ui/admin/UsersView.cpp ui/common/AuthModeModel.cpp + ui/common/LoadingIndicator.cpp ui/common/Validators.cpp ui/explore/ArtistListHelpers.cpp ui/explore/ArtistView.cpp @@ -22,6 +23,7 @@ add_executable(lms ui/explore/Explore.cpp ui/explore/Filters.cpp ui/explore/ReleaseListHelpers.cpp + ui/explore/ReleasePopup.cpp ui/explore/ReleasesView.cpp ui/explore/ReleaseView.cpp ui/explore/SearchView.cpp diff --git a/src/lms/main.cpp b/src/lms/main.cpp index 3cdfa1a1..51244cc1 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -36,7 +36,9 @@ #include "utils/Service.hpp" #include "utils/WtLogger.hpp" -std::vector generateWtConfig(std::string execPath) +static +std::vector +generateWtConfig(std::string execPath) { std::vector args; @@ -92,8 +94,16 @@ std::vector generateWtConfig(std::string execPath) } - std::ofstream oss(wtConfigPath.string().c_str(), std::ios::out); - boost::property_tree::xml_parser::write_xml(oss, pt); + { + std::ofstream oss {wtConfigPath.string().c_str(), std::ios::out}; + if (!oss) + throw LmsException {"Can't open '" + wtConfigPath.string() + "' for writing!"}; + + boost::property_tree::xml_parser::write_xml(oss, pt); + + if (!oss) + throw LmsException {"Can't write in file '" + wtConfigPath.string() + "', no space left?"}; + } return args; } diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp index 86fc7a15..de5ad4c3 100644 --- a/src/lms/ui/LmsApplication.cpp +++ b/src/lms/ui/LmsApplication.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -34,7 +35,6 @@ #include "database/User.hpp" #include "explore/Explore.hpp" #include "explore/Filters.hpp" -#include "explore/SearchView.hpp" #include "utils/Logger.hpp" #include "utils/Service.hpp" #include "utils/String.hpp" @@ -122,6 +122,7 @@ LmsApplication::LmsApplication(const Wt::WEnvironment& env, // Add a resource bundle messageResourceBundle().use(appRoot() + "admin-database"); messageResourceBundle().use(appRoot() + "admin-initwizard"); + messageResourceBundle().use(appRoot() + "admin-scannercontroller"); messageResourceBundle().use(appRoot() + "admin-user"); messageResourceBundle().use(appRoot() + "admin-users"); messageResourceBundle().use(appRoot() + "artist"); @@ -306,6 +307,13 @@ LmsApplication::createCluster(Database::Cluster::pointer cluster, bool canDelete return res; } +Wt::WPopupMenu* +LmsApplication::createPopupMenu() +{ + _popupMenu = std::make_unique(); + return _popupMenu.get(); +} + void LmsApplication::handleException(LmsApplicationException& e) { @@ -332,7 +340,6 @@ enum IdxRoot { IdxExplore = 0, IdxPlayQueue, - IdxSearch, IdxSettings, IdxAdminDatabase, IdxAdminUsers, @@ -353,9 +360,9 @@ handlePathChange(Wt::WStackedWidget* stack, bool isAdmin) { "/artist", IdxExplore, false }, { "/releases", IdxExplore, false }, { "/release", IdxExplore, false }, + { "/search", IdxExplore, false }, { "/tracks", IdxExplore, false }, { "/playqueue", IdxPlayQueue, false }, - { "/search", IdxSearch, false }, { "/settings", IdxSettings, false }, { "/admin/database", IdxAdminDatabase, true }, { "/admin/users", IdxAdminUsers, true }, @@ -469,7 +476,6 @@ LmsApplication::createHome() Explore* explore = mainStack->addNew(filters); _playQueue = mainStack->addNew(); - auto* search {mainStack->addNew(filters)}; mainStack->addNew(); searchEdit->enterPressed().connect([=] @@ -480,7 +486,7 @@ LmsApplication::createHome() searchEdit->textInput().connect([=] { setInternalPath("/search", true); - search->refreshView(searchEdit->text().toUTF8()); + explore->search(searchEdit->text()); }); // Admin stuff @@ -542,11 +548,11 @@ LmsApplication::createHome() }); }); - ServiceProvider::get()->scanInProgress().connect(this, [=] (Scanner::ScanProgressStats stats) + ServiceProvider::get()->scanInProgress().connect(this, [=] (Scanner::ScanStepStats stepStats) { Wt::WServer::instance()->post(sessionId, [=] { - _events.dbScanInProgress.emit(stats); + _events.dbScanInProgress.emit(stepStats); triggerUpdate(); }); }); diff --git a/src/lms/ui/LmsApplication.hpp b/src/lms/ui/LmsApplication.hpp index 2b3b4534..9cf31760 100644 --- a/src/lms/ui/LmsApplication.hpp +++ b/src/lms/ui/LmsApplication.hpp @@ -22,6 +22,7 @@ #include #include +#include #include "database/Db.hpp" #include "database/Session.hpp" @@ -57,7 +58,7 @@ struct Events // Database events Wt::Signal<> dbScanStarted; Wt::Signal<> dbScanned; - Wt::Signal dbScanInProgress; + Wt::Signal dbScanInProgress; Wt::Signal dbScanScheduled; }; @@ -101,6 +102,7 @@ class LmsApplication : public Wt::WApplication static Wt::WLink createReleaseLink(Wt::Dbo::ptr release); static std::unique_ptr createReleaseAnchor(Wt::Dbo::ptr release, bool addText = true); static std::unique_ptr createCluster(Wt::Dbo::ptr cluster, bool canDelete = false); + Wt::WPopupMenu* createPopupMenu(); MediaPlayer& getMediaPlayer() const { return *_mediaPlayer; } PlayQueue& getPlayQueue() const { return *_playQueue; } @@ -124,17 +126,18 @@ class LmsApplication : public Wt::WApplication void createHome(); - Wt::Signal<> _preQuit; - Database::Session _dbSession; - LmsApplicationGroupContainer& _appGroups; - Events _events; - std::optional _userId; - std::optional _userAuthStrong; + Wt::Signal<> _preQuit; + Database::Session _dbSession; + LmsApplicationGroupContainer& _appGroups; + Events _events; + std::optional _userId; + std::optional _userAuthStrong; std::shared_ptr _audioTranscodeResource; - std::shared_ptr _audioFileResource; - std::shared_ptr _imageResource; - MediaPlayer* _mediaPlayer {}; - PlayQueue* _playQueue {}; + std::shared_ptr _audioFileResource; + std::shared_ptr _imageResource; + MediaPlayer* _mediaPlayer {}; + PlayQueue* _playQueue {}; + std::unique_ptr _popupMenu {}; }; diff --git a/src/lms/ui/PlayQueue.cpp b/src/lms/ui/PlayQueue.cpp index 04a895b7..ab3d06da 100644 --- a/src/lms/ui/PlayQueue.cpp +++ b/src/lms/ui/PlayQueue.cpp @@ -32,6 +32,7 @@ #include "utils/Service.hpp" #include "utils/String.hpp" +#include "common/LoadingIndicator.hpp" #include "resource/ImageResource.hpp" #include "LmsApplication.hpp" #include "MediaPlayer.hpp" @@ -65,14 +66,7 @@ PlayQueue::PlayQueue() }); _entriesContainer = bindNew("entries"); - - _showMore = bindNew("show-more", Wt::WString::tr("Lms.Explore.show-more")); - _showMore->setHidden(true); - _showMore->clicked().connect([=] - { - addSome(); - updateCurrentTrack(true); - }); + hideLoadingIndicator(); Wt::WText* shuffleBtn = bindNew("shuffle-btn", Wt::WString::tr("Lms.PlayQueue.template.shuffle-btn"), Wt::TextFormat::XHTML); setToolTip(*shuffleBtn, Wt::WString::tr("Lms.PlayQueue.shuffle")); @@ -188,12 +182,40 @@ PlayQueue::updateRadioBtn() _radioBtn->toggleStyleClass("text-muted", !_radioMode); } +void +PlayQueue::displayLoadingIndicator() +{ + _loadingIndicator = bindWidget("loading-indicator", createLoadingIndicator()); + _loadingIndicator->scrollVisibilityChanged().connect([this](bool visible) + { + if (!visible) + return; + + addSome(); + updateCurrentTrack(true); + }); +} + +void +PlayQueue::hideLoadingIndicator() +{ + _loadingIndicator = nullptr; + bindEmpty("loading-indicator"); +} + Database::TrackList::pointer PlayQueue::getTrackList() const { return Database::TrackList::getById(LmsApp->getDbSession(), _tracklistId); } +bool +PlayQueue::isFull() const +{ + auto transaction {LmsApp->getDbSession().createSharedTransaction()}; + return getTrackList()->getCount() == _nbMaxEntries; +} + void PlayQueue::clearTracks() { @@ -202,7 +224,7 @@ PlayQueue::clearTracks() getTrackList().modify()->clear(); } - _showMore->setHidden(true); + hideLoadingIndicator(); _entriesContainer->clear(); updateInfo(); } @@ -306,55 +328,77 @@ PlayQueue::updateCurrentTrack(bool selected) entry->bindString("is-selected", selected ? "Lms-playqueue-selected" : ""); } -void +std::size_t PlayQueue::enqueueTracks(const std::vector& trackIds) { + std::size_t nbTracksQueued {}; + { auto transaction {LmsApp->getDbSession().createUniqueTransaction()}; - auto tracklist = getTrackList(); + auto tracklist {getTrackList()}; + + std::size_t nbTracksToEnqueue {tracklist->getCount() + trackIds.size() > _nbMaxEntries ? _nbMaxEntries - tracklist->getCount() : trackIds.size()}; for (Database::IdType trackId : trackIds) { Database::Track::pointer track {Database::Track::getById(LmsApp->getDbSession(), trackId)}; if (!track) continue; + if (nbTracksQueued == nbTracksToEnqueue) + break; + Database::TrackListEntry::create(LmsApp->getDbSession(), track, tracklist); + nbTracksQueued++; } } updateInfo(); addSome(); -} -void -PlayQueue::enqueueTrack(Database::IdType trackId) -{ - enqueueTracks({trackId}); + return nbTracksQueued; } void PlayQueue::processTracks(PlayQueueAction action, const std::vector& trackIds) { + std::size_t nbAddedTracks {}; + switch (action) { - case PlayQueueAction::AddLast: - enqueueTracks(trackIds); - LmsApp->notifyMsg(MsgType::Info, Wt::WString::trn("Lms.PlayQueue.nb-tracks-added", trackIds.size()).arg(trackIds.size()), std::chrono::milliseconds(2000)); - break; - - case PlayQueueAction::AddNext: + case PlayQueueAction::PlayLast: + nbAddedTracks = enqueueTracks(trackIds); + if (!_trackPos) + loadTrack(0, true); break; case PlayQueueAction::Play: clearTracks(); - enqueueTracks(trackIds); + nbAddedTracks = enqueueTracks(trackIds); loadTrack(0, true); - LmsApp->notifyMsg(MsgType::Info, Wt::WString::trn("Lms.PlayQueue.nb-tracks-playing", trackIds.size()).arg(trackIds.size()), std::chrono::milliseconds(2000)); break; + case PlayQueueAction::PlayShuffled: + { + clearTracks(); + { + std::vector shuffledTrackIds {trackIds}; + Random::shuffleContainer(shuffledTrackIds); + nbAddedTracks = enqueueTracks(shuffledTrackIds); + } + loadTrack(0, true); + break; + } } + + if (nbAddedTracks > 0) + LmsApp->notifyMsg(MsgType::Info, Wt::WString::trn("Lms.PlayQueue.nb-tracks-added", nbAddedTracks).arg(nbAddedTracks), std::chrono::milliseconds(2000)); + + if (isFull()) + LmsApp->notifyMsg(MsgType::Warning, Wt::WString::tr("Lms.PlayQueue.playqueue-full"), std::chrono::milliseconds(2000)); + + } void @@ -447,7 +491,10 @@ PlayQueue::addSome() } - _showMore->setHidden(static_cast(_entriesContainer->count()) >= tracklist->getCount()); + if (static_cast(_entriesContainer->count()) < tracklist->getCount()) + displayLoadingIndicator(); + else + hideLoadingIndicator(); } void diff --git a/src/lms/ui/PlayQueue.hpp b/src/lms/ui/PlayQueue.hpp index a2088ecd..8cfae3e6 100644 --- a/src/lms/ui/PlayQueue.hpp +++ b/src/lms/ui/PlayQueue.hpp @@ -22,7 +22,6 @@ #include #include -#include #include #include @@ -61,16 +60,18 @@ class PlayQueue : public Wt::WTemplate private: Wt::Dbo::ptr getTrackList() const; + bool isFull() const; void clearTracks(); - void enqueueTracks(const std::vector& trackIds); - void enqueueTrack(Database::IdType trackId); + std::size_t enqueueTracks(const std::vector& trackIds); void addSome(); void enqueueRadioTrack(); void updateInfo(); void updateCurrentTrack(bool selected); void updateRepeatBtn(); void updateRadioBtn(); + void displayLoadingIndicator(); + void hideLoadingIndicator(); void loadTrack(std::size_t pos, bool play); void stop(); @@ -79,12 +80,14 @@ class PlayQueue : public Wt::WTemplate void addRadioTrackFromClusters(); std::optional getReplayGain(std::size_t pos, const Wt::Dbo::ptr& track) const; + static inline constexpr std::size_t _nbMaxEntries {1000}; + bool _repeatAll {}; bool _radioMode {}; bool _mediaPlayerSettingsLoaded {}; Database::IdType _tracklistId {}; Wt::WContainerWidget* _entriesContainer {}; - Wt::WPushButton* _showMore {}; + Wt::WTemplate* _loadingIndicator {}; Wt::WText* _nbTracks {}; Wt::WText* _repeatBtn {}; Wt::WText* _radioBtn {}; diff --git a/src/lms/ui/PlayQueueAction.hpp b/src/lms/ui/PlayQueueAction.hpp index 2892537b..29a91351 100644 --- a/src/lms/ui/PlayQueueAction.hpp +++ b/src/lms/ui/PlayQueueAction.hpp @@ -19,15 +19,22 @@ #pragma once +#include +#include + +#include "database/Types.hpp" + namespace UserInterface { enum class PlayQueueAction { Play, - AddNext, - AddLast, + PlayLast, + PlayShuffled, }; + using PlayQueueActionSignal = Wt::Signal&>; + } diff --git a/src/lms/ui/admin/DatabaseSettingsView.cpp b/src/lms/ui/admin/DatabaseSettingsView.cpp index 5b612ff6..3b734728 100644 --- a/src/lms/ui/admin/DatabaseSettingsView.cpp +++ b/src/lms/ui/admin/DatabaseSettingsView.cpp @@ -34,7 +34,7 @@ #include "common/Validators.hpp" #include "common/ValueStringModel.hpp" -#include "DatabaseStatus.hpp" +#include "ScannerController.hpp" #include "LmsApplication.hpp" namespace UserInterface { @@ -220,7 +220,7 @@ DatabaseSettingsView::refreshView() Wt::WPushButton *discardBtn = t->bindWidget("discard-btn", std::make_unique(Wt::WString::tr("Lms.discard"))); Wt::WPushButton *immScanBtn = t->bindWidget("immediate-scan-btn", std::make_unique(Wt::WString::tr("Lms.Admin.Database.immediate-scan"))); - t->bindNew("status"); + t->bindNew("scanner-controller"); saveBtn->clicked().connect([=] () { @@ -230,7 +230,7 @@ DatabaseSettingsView::refreshView() { model->saveData(); - ServiceProvider::get()->requestReschedule(); + ServiceProvider::get()->requestReload(); LmsApp->notifyMsg(MsgType::Success, Wt::WString::tr("Lms.Admin.Database.settings-saved")); } @@ -247,7 +247,7 @@ DatabaseSettingsView::refreshView() immScanBtn->clicked().connect([=] () { - ServiceProvider::get()->requestImmediateScan(); + ServiceProvider::get()->requestImmediateScan(false); }); t->updateView(model.get()); diff --git a/src/lms/ui/admin/DatabaseStatus.cpp b/src/lms/ui/admin/ScannerController.cpp similarity index 56% rename from src/lms/ui/admin/DatabaseStatus.cpp rename to src/lms/ui/admin/ScannerController.cpp index 1030c452..2e971064 100644 --- a/src/lms/ui/admin/DatabaseStatus.cpp +++ b/src/lms/ui/admin/ScannerController.cpp @@ -17,14 +17,16 @@ * along with LMS. If not, see . */ -#include "DatabaseStatus.hpp" +#include "ScannerController.hpp" #include #include #include +#include #include #include +#include #include "utils/Service.hpp" #include "LmsApplication.hpp" @@ -66,7 +68,7 @@ class ReportResource : public Wt::WResource void handleRequest(const Wt::Http::Request&, Wt::Http::Response& response) { - response.out() << Wt::WString::tr("Lms.Admin.Database.Status.errors-header").arg(_stats.errors.size()).toUTF8() << std::endl; + response.out() << Wt::WString::tr("Lms.Admin.ScannerController.errors-header").arg(_stats.errors.size()).toUTF8() << std::endl; for (const auto& error : _stats.errors) { @@ -78,7 +80,7 @@ class ReportResource : public Wt::WResource response.out() << std::endl; - response.out() << Wt::WString::tr("Lms.Admin.Database.Status.duplicates-header").arg(_stats.duplicates.size()).toUTF8() << std::endl; + response.out() << Wt::WString::tr("Lms.Admin.ScannerController.duplicates-header").arg(_stats.duplicates.size()).toUTF8() << std::endl; for (const auto& duplicate : _stats.duplicates) response.out() << duplicate.file.string() << " - " << duplicateReasonToWString(duplicate.reason).toUTF8() << std::endl; @@ -90,10 +92,10 @@ class ReportResource : public Wt::WResource { switch (error) { - case Scanner::ScanErrorType::CannotReadFile: return Wt::WString::tr("Lms.Admin.Database.Status.cannot-read-file"); - case Scanner::ScanErrorType::CannotParseFile: return Wt::WString::tr("Lms.Admin.Database.Status.cannot-parse-file"); - case Scanner::ScanErrorType::NoAudioTrack: return Wt::WString::tr("Lms.Admin.Database.Status.no-audio-track"); - case Scanner::ScanErrorType::BadDuration: return Wt::WString::tr("Lms.Admin.Database.Status.bad-duration"); + case Scanner::ScanErrorType::CannotReadFile: return Wt::WString::tr("Lms.Admin.ScannerController.cannot-read-file"); + case Scanner::ScanErrorType::CannotParseFile: return Wt::WString::tr("Lms.Admin.ScannerController.cannot-parse-file"); + case Scanner::ScanErrorType::NoAudioTrack: return Wt::WString::tr("Lms.Admin.ScannerController.no-audio-track"); + case Scanner::ScanErrorType::BadDuration: return Wt::WString::tr("Lms.Admin.ScannerController.bad-duration"); } return "?"; } @@ -102,8 +104,8 @@ class ReportResource : public Wt::WResource { switch (reason) { - case Scanner::DuplicateReason::SameHash: return Wt::WString::tr("Lms.Admin.Database.Status.same-hash"); - case Scanner::DuplicateReason::SameMBID: return Wt::WString::tr("Lms.Admin.Database.Status.same-mbid"); + case Scanner::DuplicateReason::SameHash: return Wt::WString::tr("Lms.Admin.ScannerController.same-hash"); + case Scanner::DuplicateReason::SameMBID: return Wt::WString::tr("Lms.Admin.ScannerController.same-mbid"); } return "?"; } @@ -112,8 +114,8 @@ class ReportResource : public Wt::WResource }; -DatabaseStatus::DatabaseStatus() -: WTemplate {Wt::WString::tr("Lms.Admin.Database.Status.template")} +ScannerController::ScannerController() +: WTemplate {Wt::WString::tr("Lms.Admin.ScannerController.template")} { addFunction("tr", &Wt::WTemplate::Functions::tr); @@ -133,16 +135,34 @@ DatabaseStatus::DatabaseStatus() } void -DatabaseStatus::refreshContents() +ScannerController::refreshContents() { using namespace Scanner; - Wt::WPushButton* reportBtn {bindNew("btn-report", Wt::WString::tr("Lms.Admin.Database.Status.get-report"))}; + Wt::WPushButton* reportBtn {bindNew("btn-report", Wt::WString::tr("Lms.Admin.ScannerController.get-report"))}; + + Wt::WSplitButton* actionBtn {bindNew("btn-action")}; + actionBtn->actionButton()->addStyleClass("btn-primary"); + actionBtn->actionButton()->setText(Wt::WString::tr("Lms.Admin.ScannerController.scan-now")); + actionBtn->actionButton()->clicked().connect([] + { + ServiceProvider::get()->requestImmediateScan(false); + }); + + auto popup = std::make_unique(); + popup->addItem(Wt::WString::tr("Lms.Admin.ScannerController.force-scan-now")); + popup->itemSelected().connect([] + { + ServiceProvider::get()->requestImmediateScan(true); + }); + actionBtn->dropDownButton()->setMenu(std::move(popup)); + actionBtn->dropDownButton()->addStyleClass("btn-primary"); + const IMediaScanner::Status status {ServiceProvider::get()->getStatus()}; if (status.lastCompleteScanStats) { - bindString("last-scan", Wt::WString::tr("Lms.Admin.Database.Status.last-scan-status") + bindString("last-scan", Wt::WString::tr("Lms.Admin.ScannerController.last-scan-status") .arg(status.lastCompleteScanStats->nbFiles()) .arg(durationToString(status.lastCompleteScanStats->startTime, status.lastCompleteScanStats->stopTime)) .arg(status.lastCompleteScanStats->stopTime.toString()) @@ -156,7 +176,7 @@ DatabaseStatus::refreshContents() } else { - bindString("last-scan", Wt::WString::tr("Lms.Admin.Database.Status.last-scan-not-available")); + bindString("last-scan", Wt::WString::tr("Lms.Admin.ScannerController.last-scan-not-available")); reportBtn->setEnabled(false); } @@ -164,19 +184,44 @@ DatabaseStatus::refreshContents() switch (status.currentState) { case IMediaScanner::State::NotScheduled: - bindString("status", Wt::WString::tr("Lms.Admin.Database.Status.status-not-scheduled")); + bindString("status", Wt::WString::tr("Lms.Admin.ScannerController.status-not-scheduled")); + bindEmpty("step-status"); break; case IMediaScanner::State::Scheduled: - bindString("status", Wt::WString::tr("Lms.Admin.Database.Status.status-scheduled") + bindString("status", Wt::WString::tr("Lms.Admin.ScannerController.status-scheduled") .arg(status.nextScheduledScan.toString())); + bindEmpty("step-status"); break; case IMediaScanner::State::InProgress: + bindString("status", Wt::WString::tr("Lms.Admin.ScannerController.status-in-progress") + .arg(static_cast(status.currentScanStepStats->currentStep) + 1) + .arg(Scanner::ScanProgressStepCount)); + + switch (status.currentScanStepStats->currentStep) { - std::ostringstream oss; - bindString("status", Wt::WString::tr("Lms.Admin.Database.Status.status-in-progress") - .arg(status.inProgressScanStats->processedFiles) - .arg(status.inProgressScanStats->filesToScan) - .arg(status.inProgressScanStats->progress())); + case Scanner::ScanProgressStep::ChekingForMissingFiles: + bindString("step-status", Wt::WString::tr("Lms.Admin.ScannerController.step-checking-for-missing-files") + .arg(status.currentScanStepStats->progress())); + break; + + case Scanner::ScanProgressStep::DiscoveringFiles: + bindString("step-status", Wt::WString::tr("Lms.Admin.ScannerController.step-discovering-files") + .arg(status.currentScanStepStats->processedFiles)); + break; + + case Scanner::ScanProgressStep::ScanningFiles: + bindString("step-status", Wt::WString::tr("Lms.Admin.ScannerController.step-scanning-files") + .arg(status.currentScanStepStats->processedFiles) + .arg(status.currentScanStepStats->filesToProcess) + .arg(status.currentScanStepStats->progress())); + break; + + case Scanner::ScanProgressStep::FetchingTrackFeatures: + bindString("step-status", Wt::WString::tr("Lms.Admin.ScannerController.step-fetching-track-features") + .arg(status.currentScanStepStats->processedFiles) + .arg(status.currentScanStepStats->filesToProcess) + .arg(status.currentScanStepStats->progress())); + break; } break; } diff --git a/src/lms/ui/admin/DatabaseStatus.hpp b/src/lms/ui/admin/ScannerController.hpp similarity index 83% rename from src/lms/ui/admin/DatabaseStatus.hpp rename to src/lms/ui/admin/ScannerController.hpp index d970ca36..498d28fe 100644 --- a/src/lms/ui/admin/DatabaseStatus.hpp +++ b/src/lms/ui/admin/ScannerController.hpp @@ -21,16 +21,17 @@ #include -namespace UserInterface { - -class DatabaseStatus : public Wt::WTemplate +namespace UserInterface { - public: - DatabaseStatus(); - private: - void refreshContents(); -}; + class ScannerController : public Wt::WTemplate + { + public: + ScannerController(); + + private: + void refreshContents(); + }; } // namespace DatabaseStatus diff --git a/src/lms/ui/common/LoadingIndicator.cpp b/src/lms/ui/common/LoadingIndicator.cpp new file mode 100644 index 00000000..d3063f1a --- /dev/null +++ b/src/lms/ui/common/LoadingIndicator.cpp @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2020 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "LoadingIndicator.hpp" + +namespace UserInterface +{ + std::unique_ptr + createLoadingIndicator() + { + auto res {std::make_unique(Wt::WString::tr("Lms.LoadingIndicator.template"))}; + + res->addFunction("tr", &Wt::WTemplate::Functions::tr); + res->setScrollVisibilityMargin(200); + res->setScrollVisibilityEnabled(true); + + return res; + } +} + diff --git a/src/lms/ui/common/LoadingIndicator.hpp b/src/lms/ui/common/LoadingIndicator.hpp new file mode 100644 index 00000000..e118f567 --- /dev/null +++ b/src/lms/ui/common/LoadingIndicator.hpp @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2020 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include + +namespace UserInterface +{ + std::unique_ptr createLoadingIndicator(); +} + diff --git a/src/lms/ui/explore/ArtistView.cpp b/src/lms/ui/explore/ArtistView.cpp index bdbe5602..082f0634 100644 --- a/src/lms/ui/explore/ArtistView.cpp +++ b/src/lms/ui/explore/ArtistView.cpp @@ -115,11 +115,24 @@ Artist::refreshView() } { - Wt::WText* addBtn = bindNew("add-btn", Wt::WString::tr("Lms.Explore.template.add-btn"), Wt::TextFormat::XHTML); + Wt::WText* moreBtn = bindNew("more-btn", Wt::WString::tr("Lms.Explore.template.more-btn"), Wt::TextFormat::XHTML); - addBtn->clicked().connect([=] + moreBtn->clicked().connect([=] { - artistsAction.emit(PlayQueueAction::AddLast, {*artistId}); + Wt::WPopupMenu* popup {LmsApp->createPopupMenu()}; + + popup->addItem(Wt::WString::tr("Lms.Explore.play-shuffled")) + ->triggered().connect(this, [=] + { + artistsAction.emit(PlayQueueAction::PlayShuffled, {*artistId}); + }); + popup->addItem(Wt::WString::tr("Lms.Explore.play-last")) + ->triggered().connect(this, [=] + { + artistsAction.emit(PlayQueueAction::PlayLast, {*artistId}); + }); + + popup->exec(moreBtn); }); } @@ -135,6 +148,10 @@ Artist::refreshView() void Artist::refreshSimilarArtists(const std::vector& similarArtistsId) { + if (similarArtistsId.empty()) + return; + + setCondition("if-has-similar-artists", true); Wt::WContainerWidget* similarArtistsContainer {bindNew("similar-artists")}; for (Database::IdType artistId : similarArtistsId) diff --git a/src/lms/ui/explore/ArtistView.hpp b/src/lms/ui/explore/ArtistView.hpp index 944c9504..54639412 100644 --- a/src/lms/ui/explore/ArtistView.hpp +++ b/src/lms/ui/explore/ArtistView.hpp @@ -42,7 +42,7 @@ class Artist : public Wt::WTemplate public: Artist(Filters* filters); - Wt::Signal&> artistsAction; + PlayQueueActionSignal artistsAction; private: void refreshView(); diff --git a/src/lms/ui/explore/ArtistsView.cpp b/src/lms/ui/explore/ArtistsView.cpp index a2fae1f5..0084d009 100644 --- a/src/lms/ui/explore/ArtistsView.cpp +++ b/src/lms/ui/explore/ArtistsView.cpp @@ -29,6 +29,7 @@ #include "database/TrackList.hpp" #include "utils/Logger.hpp" +#include "common/LoadingIndicator.hpp" #include "ArtistListHelpers.hpp" #include "LmsApplication.hpp" #include "Filters.hpp" @@ -75,12 +76,7 @@ Artists::Artists(Filters* filters) _linkType->changed().connect([this] { refreshView(); }); _container = bindNew("artists"); - - _showMore = bindNew("show-more", Wt::WString::tr("Lms.Explore.show-more")); - _showMore->clicked().connect([=] - { - addSome(); - }); + hideLoadingIndicator(); refreshView(); @@ -102,6 +98,26 @@ Artists::refreshView(Mode mode) refreshView(); } +void +Artists::displayLoadingIndicator() +{ + _loadingIndicator = bindWidget("loading-indicator", createLoadingIndicator()); + _loadingIndicator->scrollVisibilityChanged().connect([this](bool visible) + { + if (!visible) + return; + + addSome(); + }); +} + +void +Artists::hideLoadingIndicator() +{ + _loadingIndicator = nullptr; + bindEmpty("loading-indicator"); +} + std::vector Artists::getRandomArtists(std::optional range, bool& moreResults) { @@ -203,7 +219,10 @@ Artists::addSome() _container->addWidget(ArtistListHelpers::createEntry(artist)); } - _showMore->setHidden(!moreResults); + if (moreResults) + displayLoadingIndicator(); + else + hideLoadingIndicator(); } } // namespace UserInterface diff --git a/src/lms/ui/explore/ArtistsView.hpp b/src/lms/ui/explore/ArtistsView.hpp index 546290eb..1daf80ed 100644 --- a/src/lms/ui/explore/ArtistsView.hpp +++ b/src/lms/ui/explore/ArtistsView.hpp @@ -24,9 +24,7 @@ #include #include -#include #include -#include #include "database/Types.hpp" @@ -56,6 +54,8 @@ class Artists : public Wt::WTemplate void refreshView(); void refreshView(Mode mode); + void displayLoadingIndicator(); + void hideLoadingIndicator(); void addSome(); std::vector> getArtists(std::optional range, bool& moreResults); @@ -67,15 +67,15 @@ class Artists : public Wt::WTemplate { {Mode::Random, batchSize * 4}, {Mode::RecentlyPlayed, batchSize * 4}, - {Mode::RecentlyAdded, batchSize * 2}, - {Mode::MostPlayed, batchSize * 2}, + {Mode::RecentlyAdded, batchSize * 4}, + {Mode::MostPlayed, batchSize * 4}, {Mode::All, batchSize * 50}, }; Mode _mode {defaultMode}; std::vector _randomArtists; Filters* _filters {}; - Wt::WPushButton* _showMore {}; + Wt::WTemplate* _loadingIndicator {}; Wt::WContainerWidget* _container {}; Wt::WComboBox* _linkType {}; }; diff --git a/src/lms/ui/explore/Explore.cpp b/src/lms/ui/explore/Explore.cpp index 463ea8fd..6e89977d 100644 --- a/src/lms/ui/explore/Explore.cpp +++ b/src/lms/ui/explore/Explore.cpp @@ -35,6 +35,7 @@ #include "Filters.hpp" #include "ReleasesView.hpp" #include "ReleaseView.hpp" +#include "SearchView.hpp" #include "TracksView.hpp" namespace UserInterface { @@ -50,6 +51,7 @@ handleContentsPathChange(Wt::WStackedWidget* stack) IdxArtist, IdxReleases, IdxRelease, + IdxSearch, IdxTracks, }; @@ -59,6 +61,7 @@ handleContentsPathChange(Wt::WStackedWidget* stack) { "/artist", IdxArtist }, { "/releases", IdxReleases }, { "/release", IdxRelease }, + { "/search", IdxSearch }, { "/tracks", IdxTracks }, }; @@ -100,6 +103,11 @@ Explore::Explore(Filters* filters) release->tracksAction.connect(this, &Explore::handleTracksAction); contentsStack->addWidget(std::move(release)); + auto search = std::make_unique(_filters); + search->tracksAction.connect(this, &Explore::handleTracksAction); + _search = search.get(); + contentsStack->addWidget(std::move(search)); + auto tracks = std::make_unique(_filters); tracks->tracksAction.connect(this, &Explore::handleTracksAction); contentsStack->addWidget(std::move(tracks)); @@ -112,6 +120,12 @@ Explore::Explore(Filters* filters) handleContentsPathChange(contentsStack); } +void +Explore::search(const Wt::WString& searchText) +{ + _search->refreshView(searchText); +} + static std::vector getArtistsTracks(Database::Session& session, const std::vector& artistsId, const std::set&) diff --git a/src/lms/ui/explore/Explore.hpp b/src/lms/ui/explore/Explore.hpp index 7f476f64..2db65540 100644 --- a/src/lms/ui/explore/Explore.hpp +++ b/src/lms/ui/explore/Explore.hpp @@ -27,13 +27,16 @@ namespace UserInterface { class Filters; +class SearchView; class Explore : public Wt::WTemplate { public: Explore(Filters* filters); - Wt::Signal&> tracksAction; + void search(const Wt::WString& searchText); + + PlayQueueActionSignal tracksAction; private: @@ -42,6 +45,7 @@ class Explore : public Wt::WTemplate void handleTracksAction(PlayQueueAction action, const std::vector& tracksId); Filters* _filters {}; + SearchView* _search {}; }; } // namespace UserInterface diff --git a/src/lms/ui/explore/ReleasePopup.cpp b/src/lms/ui/explore/ReleasePopup.cpp new file mode 100644 index 00000000..7ce4d099 --- /dev/null +++ b/src/lms/ui/explore/ReleasePopup.cpp @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2020 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "ReleasePopup.hpp" + +#include "LmsApplication.hpp" + +namespace UserInterface +{ + + void + displayReleasePopupMenu(Wt::WInteractWidget& target, + Database::IdType releaseId, + PlayQueueActionSignal& releasesAction) + { + Wt::WPopupMenu* popup {LmsApp->createPopupMenu()}; + + popup->addItem(Wt::WString::tr("Lms.Explore.play-shuffled")) + ->triggered().connect(&target, [&releasesAction, releaseId] + { + releasesAction.emit(PlayQueueAction::PlayShuffled, {releaseId}); + }); + popup->addItem(Wt::WString::tr("Lms.Explore.play-last")) + ->triggered().connect(&target, [&releasesAction, releaseId] + { + releasesAction.emit(PlayQueueAction::PlayLast, {releaseId}); + }); + + popup->popup(&target); + } + +} // namespace UserInterface + diff --git a/src/lms/ui/explore/ReleasePopup.hpp b/src/lms/ui/explore/ReleasePopup.hpp new file mode 100644 index 00000000..23e576b2 --- /dev/null +++ b/src/lms/ui/explore/ReleasePopup.hpp @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2020 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include +#include + +#include "database/Types.hpp" +#include "PlayQueueAction.hpp" + +namespace UserInterface +{ + void displayReleasePopupMenu(Wt::WInteractWidget& target, + Database::IdType releaseId, + PlayQueueActionSignal& releasesAction); +} // namespace UserInterface + diff --git a/src/lms/ui/explore/ReleaseView.cpp b/src/lms/ui/explore/ReleaseView.cpp index e19f83f9..aa2d4350 100644 --- a/src/lms/ui/explore/ReleaseView.cpp +++ b/src/lms/ui/explore/ReleaseView.cpp @@ -22,7 +22,6 @@ #include #include #include -#include #include #include @@ -39,6 +38,7 @@ #include "LmsApplicationException.hpp" #include "MediaPlayer.hpp" #include "ReleaseListHelpers.hpp" +#include "ReleasePopup.hpp" #include "TrackStringUtils.hpp" using namespace Database; @@ -157,10 +157,10 @@ Release::refreshView() } { - Wt::WText* addBtn {bindNew("add-btn", Wt::WString::tr("Lms.Explore.template.add-btn"), Wt::TextFormat::XHTML)}; - addBtn->clicked().connect([=] + Wt::WText* moreBtn {bindNew("more-btn", Wt::WString::tr("Lms.Explore.template.more-btn"), Wt::TextFormat::XHTML)}; + moreBtn->clicked().connect([=] { - releasesAction.emit(PlayQueueAction::AddLast, {*releaseId}); + displayReleasePopupMenu(*moreBtn, *releaseId, releasesAction); }); } @@ -237,10 +237,18 @@ Release::refreshView() tracksAction.emit(PlayQueueAction::Play, {trackId}); }); - Wt::WText* addBtn {entry->bindNew("add-btn", Wt::WString::tr("Lms.Explore.template.add-btn"), Wt::TextFormat::XHTML)}; - addBtn->clicked().connect([=]() + Wt::WText* moreBtn {entry->bindNew("more-btn", Wt::WString::tr("Lms.Explore.template.more-btn"), Wt::TextFormat::XHTML)}; + moreBtn->clicked().connect([=]() { - tracksAction.emit(PlayQueueAction::AddLast, {trackId}); + Wt::WPopupMenu* popup {LmsApp->createPopupMenu()}; + + popup->addItem(Wt::WString::tr("Lms.Explore.play-last")) + ->triggered().connect(moreBtn, [=] + { + tracksAction.emit(PlayQueueAction::PlayLast, {trackId}); + }); + + popup->popup(moreBtn); }); entry->bindString("duration", trackDurationToString(track->getDuration()), Wt::TextFormat::Plain); @@ -306,6 +314,10 @@ Release::refreshLinks(const Database::Release::pointer& release) void Release::refreshSimilarReleases(const std::vector& similarReleasesId) { + if (similarReleasesId.empty()) + return; + + setCondition("if-has-similar-releases", true); auto* similarReleasesContainer {bindNew("similar-releases")}; for (Database::IdType id : similarReleasesId) diff --git a/src/lms/ui/explore/ReleaseView.hpp b/src/lms/ui/explore/ReleaseView.hpp index 5f1db58e..7908c405 100644 --- a/src/lms/ui/explore/ReleaseView.hpp +++ b/src/lms/ui/explore/ReleaseView.hpp @@ -19,7 +19,6 @@ #pragma once -#include #include #include "database/Types.hpp" @@ -39,11 +38,8 @@ namespace UserInterface public: Release(Filters* filters); - Wt::Signal&> releasesAction; - Wt::Signal&> tracksAction; - - Wt::Signal createReleaseActionMenu; - Wt::Signal createTrackActionMenu; + PlayQueueActionSignal releasesAction; + PlayQueueActionSignal tracksAction; private: void refreshView(); diff --git a/src/lms/ui/explore/ReleasesView.cpp b/src/lms/ui/explore/ReleasesView.cpp index a6a4e254..dd713fef 100644 --- a/src/lms/ui/explore/ReleasesView.cpp +++ b/src/lms/ui/explore/ReleasesView.cpp @@ -32,6 +32,7 @@ #include "utils/Logger.hpp" #include "utils/String.hpp" +#include "common/LoadingIndicator.hpp" #include "resource/ImageResource.hpp" #include "ReleaseListHelpers.hpp" #include "Filters.hpp" @@ -71,19 +72,27 @@ _filters {filters} { releasesAction.emit(PlayQueueAction::Play, getAllReleases()); }); - Wt::WText* addBtn {bindNew("add-btn", Wt::WString::tr("Lms.Explore.template.add-btn"), Wt::TextFormat::XHTML)}; - addBtn->clicked().connect([this] + Wt::WText* moreBtn {bindNew("more-btn", Wt::WString::tr("Lms.Explore.template.more-btn"), Wt::TextFormat::XHTML)}; + moreBtn->clicked().connect([=] { - releasesAction.emit(PlayQueueAction::AddLast, getAllReleases()); + Wt::WPopupMenu* popup {LmsApp->createPopupMenu()}; + + popup->addItem(Wt::WString::tr("Lms.Explore.play-shuffled")) + ->triggered().connect([this] + { + releasesAction.emit(PlayQueueAction::PlayShuffled, getAllReleases()); + }); + popup->addItem(Wt::WString::tr("Lms.Explore.play-last")) + ->triggered().connect([this] + { + releasesAction.emit(PlayQueueAction::PlayLast, getAllReleases()); + }); + + popup->popup(moreBtn); }); _container = bindNew("releases"); - - _showMore = bindNew("show-more", Wt::WString::tr("Lms.Explore.show-more")); - _showMore->clicked().connect([this] - { - addSome(); - }); + hideLoadingIndicator(); refreshView(defaultMode); @@ -105,6 +114,26 @@ Releases::refreshView(Mode mode) refreshView(); } +void +Releases::displayLoadingIndicator() +{ + _loadingIndicator = bindWidget("loading-indicator", createLoadingIndicator()); + _loadingIndicator->scrollVisibilityChanged().connect([this](bool visible) + { + if (!visible) + return; + + addSome(); + }); +} + +void +Releases::hideLoadingIndicator() +{ + _loadingIndicator = nullptr; + bindEmpty("loading-indicator"); +} + void Releases::addSome() { @@ -118,7 +147,10 @@ Releases::addSome() _container->addWidget(ReleaseListHelpers::createEntry(release)); } - _showMore->setHidden(!moreResults); + if (moreResults) + displayLoadingIndicator(); + else + hideLoadingIndicator(); } std::vector diff --git a/src/lms/ui/explore/ReleasesView.hpp b/src/lms/ui/explore/ReleasesView.hpp index e873d2f4..479002c3 100644 --- a/src/lms/ui/explore/ReleasesView.hpp +++ b/src/lms/ui/explore/ReleasesView.hpp @@ -22,7 +22,6 @@ #include #include -#include #include #include "database/Types.hpp" @@ -42,7 +41,7 @@ class Releases : public Wt::WTemplate public: Releases(Filters* filters); - Wt::Signal&> releasesAction; + PlayQueueActionSignal releasesAction; private: @@ -57,19 +56,23 @@ class Releases : public Wt::WTemplate void refreshView(); void refreshView(Mode mode); + void displayLoadingIndicator(); + void hideLoadingIndicator(); + void addSome(); std::vector> getReleases(std::optional range, bool& moreResults); std::vector> getRandomReleases(std::optional range, bool& moreResults); std::vector getAllReleases(); static constexpr Mode defaultMode {Mode::Random}; - static constexpr std::size_t batchSize {18}; + static constexpr std::size_t maxItemsPerLine {6}; + static constexpr std::size_t batchSize {maxItemsPerLine * 3}; static inline std::unordered_map> maxItemsPerMode { - {Mode::Random, batchSize * 6}, - {Mode::RecentlyPlayed, batchSize * 3}, - {Mode::RecentlyAdded, batchSize * 2}, - {Mode::MostPlayed, batchSize * 2}, + {Mode::Random, batchSize * 10}, + {Mode::RecentlyPlayed, batchSize * 10}, + {Mode::RecentlyAdded, batchSize * 10}, + {Mode::MostPlayed, batchSize * 10}, {Mode::All, batchSize * 30}, }; @@ -77,7 +80,7 @@ class Releases : public Wt::WTemplate Filters* _filters {}; std::vector _randomReleases; Wt::WContainerWidget* _container {}; - Wt::WPushButton* _showMore {}; + Wt::WTemplate* _loadingIndicator {}; }; } // namespace UserInterface diff --git a/src/lms/ui/explore/SearchView.cpp b/src/lms/ui/explore/SearchView.cpp index 6f37b4c6..bd8a8e09 100644 --- a/src/lms/ui/explore/SearchView.cpp +++ b/src/lms/ui/explore/SearchView.cpp @@ -51,9 +51,9 @@ namespace UserInterface } void - SearchView::refreshView(const std::string& searchText) + SearchView::refreshView(const Wt::WString& searchText) { - _keywords = StringUtils::splitString(searchText, " "); + _keywords = StringUtils::splitString(searchText.toUTF8(), " "); refreshView(); } @@ -127,7 +127,7 @@ namespace UserInterface auto* container {bindNew("tracks")}; for (const Database::Track::pointer& track : tracks) - container->addWidget(TrackListHelpers::createEntry(track)); + container->addWidget(TrackListHelpers::createEntry(track, tracksAction)); } } diff --git a/src/lms/ui/explore/SearchView.hpp b/src/lms/ui/explore/SearchView.hpp index a7b2460a..8711547d 100644 --- a/src/lms/ui/explore/SearchView.hpp +++ b/src/lms/ui/explore/SearchView.hpp @@ -24,6 +24,8 @@ #include +#include "PlayQueueAction.hpp" + namespace UserInterface { class Filters; @@ -33,7 +35,9 @@ class SearchView : public Wt::WTemplate public: SearchView(Filters* filters); - void refreshView(const std::string& searchText); + PlayQueueActionSignal tracksAction; + + void refreshView(const Wt::WString& searchText); private: void refreshView(); diff --git a/src/lms/ui/explore/TrackListHelpers.cpp b/src/lms/ui/explore/TrackListHelpers.cpp index 3d4ec648..649f92d5 100644 --- a/src/lms/ui/explore/TrackListHelpers.cpp +++ b/src/lms/ui/explore/TrackListHelpers.cpp @@ -30,7 +30,6 @@ #include "resource/ImageResource.hpp" #include "LmsApplication.hpp" #include "MediaPlayer.hpp" -#include "PlayQueue.hpp" #include "TrackStringUtils.hpp" using namespace Database; @@ -38,7 +37,7 @@ using namespace Database; namespace UserInterface::TrackListHelpers { std::unique_ptr - createEntry(const Wt::Dbo::ptr& track) + createEntry(const Wt::Dbo::ptr& track, PlayQueueActionSignal& tracksAction) { auto entry {std::make_unique(Wt::WString::tr("Lms.Explore.Tracks.template.entry"))}; auto* entryPtr {entry.get()}; @@ -89,15 +88,23 @@ namespace UserInterface::TrackListHelpers entry->bindString("duration", trackDurationToString(track->getDuration()), Wt::TextFormat::Plain); Wt::WText* playBtn = entry->bindNew("play-btn", Wt::WString::tr("Lms.Explore.template.play-btn"), Wt::TextFormat::XHTML); - playBtn->clicked().connect([=] + playBtn->clicked().connect([trackId, &tracksAction] { - LmsApp->getPlayQueue().processTracks(PlayQueueAction::Play, {trackId}); + tracksAction.emit(PlayQueueAction::Play, {trackId}); }); - Wt::WText* addBtn = entry->bindNew("add-btn", Wt::WString::tr("Lms.Explore.template.add-btn"), Wt::TextFormat::XHTML); - addBtn->clicked().connect([=] + Wt::WText* moreBtn = entry->bindNew("more-btn", Wt::WString::tr("Lms.Explore.template.more-btn"), Wt::TextFormat::XHTML); + moreBtn->clicked().connect([=, &tracksAction] { - LmsApp->getPlayQueue().processTracks(PlayQueueAction::AddLast, {trackId}); + Wt::WPopupMenu* popup {LmsApp->createPopupMenu()}; + + popup->addItem(Wt::WString::tr("Lms.Explore.play-last")) + ->triggered().connect(moreBtn, [=, &tracksAction] + { + tracksAction.emit(PlayQueueAction::PlayLast, {trackId}); + }); + + popup->popup(moreBtn); }); LmsApp->getMediaPlayer().trackLoaded.connect(entryPtr, [=] (Database::IdType loadedTrackId) diff --git a/src/lms/ui/explore/TrackListHelpers.hpp b/src/lms/ui/explore/TrackListHelpers.hpp index 2766e3e8..32ac5b12 100644 --- a/src/lms/ui/explore/TrackListHelpers.hpp +++ b/src/lms/ui/explore/TrackListHelpers.hpp @@ -22,6 +22,7 @@ #include #include +#include "PlayQueueAction.hpp" namespace Database { @@ -30,6 +31,6 @@ namespace Database namespace UserInterface::TrackListHelpers { - std::unique_ptr createEntry(const Wt::Dbo::ptr& track); + std::unique_ptr createEntry(const Wt::Dbo::ptr& track, PlayQueueActionSignal& tracksAction); } // namespace UserInterface diff --git a/src/lms/ui/explore/TracksView.cpp b/src/lms/ui/explore/TracksView.cpp index db852f48..c9451d85 100644 --- a/src/lms/ui/explore/TracksView.cpp +++ b/src/lms/ui/explore/TracksView.cpp @@ -32,8 +32,8 @@ #include "utils/Logger.hpp" #include "utils/String.hpp" +#include "common/LoadingIndicator.hpp" #include "resource/ImageResource.hpp" - #include "Filters.hpp" #include "LmsApplication.hpp" #include "MediaPlayer.hpp" @@ -75,19 +75,27 @@ _filters {filters} tracksAction.emit(PlayQueueAction::Play, getAllTracks()); }); - Wt::WText* addBtn = bindNew("add-btn", Wt::WString::tr("Lms.Explore.template.add-btn"), Wt::TextFormat::XHTML); - addBtn->clicked().connect([=] + Wt::WText* moreBtn = bindNew("more-btn", Wt::WString::tr("Lms.Explore.template.more-btn"), Wt::TextFormat::XHTML); + moreBtn->clicked().connect([=] { - tracksAction.emit(PlayQueueAction::AddLast, getAllTracks()); + Wt::WPopupMenu* popup {LmsApp->createPopupMenu()}; + + popup->addItem(Wt::WString::tr("Lms.Explore.play-shuffled")) + ->triggered().connect(this, [this] + { + tracksAction.emit(PlayQueueAction::PlayShuffled, getAllTracks()); + }); + popup->addItem(Wt::WString::tr("Lms.Explore.play-last")) + ->triggered().connect(this, [this] + { + tracksAction.emit(PlayQueueAction::PlayLast, getAllTracks()); + }); + + popup->popup(moreBtn); }); _tracksContainer = bindNew("tracks"); - - _showMore = bindNew("show-more", Wt::WString::tr("Lms.Explore.show-more")); - _showMore->clicked().connect([this] - { - addSome(); - }); + hideLoadingIndicator(); filters->updated().connect([this] { @@ -112,6 +120,26 @@ Tracks::refreshView(Mode mode) refreshView(); } +void +Tracks::displayLoadingIndicator() +{ + _loadingIndicator = bindWidget("loading-indicator", createLoadingIndicator()); + _loadingIndicator->scrollVisibilityChanged().connect([this](bool visible) + { + if (!visible) + return; + + addSome(); + }); +} + +void +Tracks::hideLoadingIndicator() +{ + _loadingIndicator = nullptr; + bindEmpty("loading-indicator"); +} + std::vector Tracks::getRandomTracks(std::optional range, bool& moreResults) { @@ -203,10 +231,13 @@ Tracks::addSome() bool moreResults; for (const Track::pointer& track : getTracks(Range {static_cast(_tracksContainer->count()), batchSize}, moreResults)) { - _tracksContainer->addWidget(TrackListHelpers::createEntry(track)); + _tracksContainer->addWidget(TrackListHelpers::createEntry(track, tracksAction)); } - _showMore->setHidden(!moreResults); + if (moreResults) + displayLoadingIndicator(); + else + hideLoadingIndicator(); } } // namespace UserInterface diff --git a/src/lms/ui/explore/TracksView.hpp b/src/lms/ui/explore/TracksView.hpp index 430f8a41..d66f3b14 100644 --- a/src/lms/ui/explore/TracksView.hpp +++ b/src/lms/ui/explore/TracksView.hpp @@ -22,8 +22,6 @@ #include #include -#include -#include #include #include "database/Types.hpp" @@ -42,7 +40,7 @@ class Tracks : public Wt::WTemplate public: Tracks(Filters* filters); - Wt::Signal&> tracksAction; + PlayQueueActionSignal tracksAction; private: @@ -57,6 +55,8 @@ class Tracks : public Wt::WTemplate void refreshView(); void refreshView(Mode mode); + void displayLoadingIndicator(); + void hideLoadingIndicator(); void addSome(); std::vector> getRandomTracks(std::optional range, bool& moreResults); @@ -78,8 +78,7 @@ class Tracks : public Wt::WTemplate Filters* _filters {}; std::vector _randomTracks; Wt::WContainerWidget* _tracksContainer {}; - Wt::WPushButton* _showMore {}; - Wt::WLineEdit* _search {}; + Wt::WTemplate* _loadingIndicator {}; }; } // namespace UserInterface