1 /*
2 * Copyright (C) 2018 Emeric Poupon
3 *
4 * This file is part of LMS.
5 *
6 * LMS is free software: you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 *
11 * LMS is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with LMS. If not, see <http://www.gnu.org/licenses/>.
18 */
19
20 #include "LmsApplication.hpp"
21
22 #include <Wt/WAnchor.h>
23 #include <Wt/WEnvironment.h>
24 #include <Wt/WLineEdit.h>
25 #include <Wt/WPopupMenu.h>
26 #include <Wt/WPushButton.h>
27 #include <Wt/WServer.h>
28 #include <Wt/WStackedWidget.h>
29 #include <Wt/WText.h>
30
31 #include "auth/IEnvService.hpp"
32 #include "auth/IPasswordService.hpp"
33 #include "cover/ICoverArtGrabber.hpp"
34 #include "database/Artist.hpp"
35 #include "database/Cluster.hpp"
36 #include "database/Db.hpp"
37 #include "database/Release.hpp"
38 #include "database/Session.hpp"
39 #include "database/User.hpp"
40 #include "scrobbling/IScrobbling.hpp"
41 #include "utils/Logger.hpp"
42 #include "utils/Service.hpp"
43 #include "utils/String.hpp"
44
45 #include "admin/InitWizardView.hpp"
46 #include "admin/DatabaseSettingsView.hpp"
47 #include "admin/UserView.hpp"
48 #include "admin/UsersView.hpp"
49 #include "explore/Explore.hpp"
50 #include "explore/Filters.hpp"
51 #include "resource/AudioFileResource.hpp"
52 #include "resource/AudioTranscodeResource.hpp"
53 #include "resource/DownloadResource.hpp"
54 #include "resource/CoverResource.hpp"
55 #include "Auth.hpp"
56 #include "LmsApplicationException.hpp"
57 #include "LmsApplicationManager.hpp"
58 #include "LmsTheme.hpp"
59 #include "MediaPlayer.hpp"
60 #include "PlayQueue.hpp"
61 #include "SettingsView.hpp"
62
63 namespace UserInterface {
64
65 static constexpr const char* defaultPath {"/releases"};
66
67 std::unique_ptr<Wt::WApplication>
create(const Wt::WEnvironment & env,Database::Db & db,LmsApplicationManager & appManager)68 LmsApplication::create(const Wt::WEnvironment& env, Database::Db& db, LmsApplicationManager& appManager)
69 {
70 if (auto *authEnvService {Service<::Auth::IEnvService>::get()})
71 {
72 const auto checkResult {authEnvService->processEnv(db.getTLSSession(), env)};
73 if (checkResult.state != ::Auth::IEnvService::CheckResult::State::Granted)
74 {
75 LMS_LOG(UI, ERROR) << "Cannot authenticate user from environment!";
76 // return a blank page
77 return std::make_unique<Wt::WApplication>(env);
78 }
79
80 return std::make_unique<LmsApplication>(env, db, appManager, checkResult.userId);
81 }
82
83 return std::make_unique<LmsApplication>(env, db, appManager);
84 }
85
86 LmsApplication*
instance()87 LmsApplication::instance()
88 {
89 return reinterpret_cast<LmsApplication*>(Wt::WApplication::instance());
90 }
91
92 Database::Session&
getDbSession()93 LmsApplication::getDbSession()
94 {
95 return _db.getTLSSession();
96 }
97
98 Wt::Dbo::ptr<Database::User>
getUser()99 LmsApplication::getUser()
100 {
101 if (!_authenticatedUser)
102 return {};
103
104 return Database::User::getById(getDbSession(), _authenticatedUser->userId);
105 }
106
107 Database::IdType
getUserId()108 LmsApplication::getUserId()
109 {
110 return _authenticatedUser->userId;
111 }
112
113 bool
isUserAuthStrong() const114 LmsApplication::isUserAuthStrong() const
115 {
116 return _authenticatedUser->strongAuth;
117 }
118
119 bool
isUserAdmin()120 LmsApplication::isUserAdmin()
121 {
122 auto transaction {getDbSession().createSharedTransaction()};
123
124 return getUser()->isAdmin();
125 }
126
127 bool
isUserDemo()128 LmsApplication::isUserDemo()
129 {
130 auto transaction {getDbSession().createSharedTransaction()};
131
132 return getUser()->isDemo();
133 }
134
135 std::string
getUserLoginName()136 LmsApplication::getUserLoginName()
137 {
138 auto transaction {getDbSession().createSharedTransaction()};
139
140 return getUser()->getLoginName();
141 }
142
LmsApplication(const Wt::WEnvironment & env,Database::Db & db,LmsApplicationManager & appManager,std::optional<Database::IdType> userId)143 LmsApplication::LmsApplication(const Wt::WEnvironment& env,
144 Database::Db& db,
145 LmsApplicationManager& appManager,
146 std::optional<Database::IdType> userId)
147 : Wt::WApplication {env}
148 , _db {db}
149 , _appManager {appManager}
150 , _authenticatedUser {userId ? std::make_optional<UserAuthInfo>(UserAuthInfo {*userId, false}) : std::nullopt}
151 {
152 try
153 {
154 init();
155 }
156 catch (LmsApplicationException& e)
157 {
158 LMS_LOG(UI, WARNING) << "Caught a LmsApplication exception: " << e.what();
159 handleException(e);
160 }
161 catch (std::exception& e)
162 {
163 LMS_LOG(UI, ERROR) << "Caught exception: " << e.what();
164 throw LmsException {"Internal error"}; // Do not put details here at it may appear on the user rendered html
165 }
166 }
167
168 LmsApplication::~LmsApplication() = default;
169
170 void
init()171 LmsApplication::init()
172 {
173 useStyleSheet("resources/font-awesome/css/font-awesome.min.css");
174
175 // Add a resource bundle
176 messageResourceBundle().use(appRoot() + "admin-database");
177 messageResourceBundle().use(appRoot() + "admin-initwizard");
178 messageResourceBundle().use(appRoot() + "admin-scannercontroller");
179 messageResourceBundle().use(appRoot() + "admin-user");
180 messageResourceBundle().use(appRoot() + "admin-users");
181 messageResourceBundle().use(appRoot() + "artist");
182 messageResourceBundle().use(appRoot() + "artists");
183 messageResourceBundle().use(appRoot() + "error");
184 messageResourceBundle().use(appRoot() + "explore");
185 messageResourceBundle().use(appRoot() + "login");
186 messageResourceBundle().use(appRoot() + "mediaplayer");
187 messageResourceBundle().use(appRoot() + "messages");
188 messageResourceBundle().use(appRoot() + "playqueue");
189 messageResourceBundle().use(appRoot() + "release");
190 messageResourceBundle().use(appRoot() + "releases");
191 messageResourceBundle().use(appRoot() + "search");
192 messageResourceBundle().use(appRoot() + "settings");
193 messageResourceBundle().use(appRoot() + "templates");
194 messageResourceBundle().use(appRoot() + "tracks");
195
196 // Require js here to avoid async problems
197 requireJQuery("js/jquery-1.10.2.min.js");
198 require("js/bootstrap-notify.js");
199 require("js/bootstrap.min.js");
200 require("js/mediaplayer.js");
201
202 setTitle("LMS");
203
204 // Handle Media Scanner events and other session events
205 enableUpdates(true);
206
207 if (_authenticatedUser)
208 {
209 onUserLoggedIn();
210 }
211 else if (Service<::Auth::IPasswordService>::exists())
212 processPasswordAuth();
213 }
214
215 void
processPasswordAuth()216 LmsApplication::processPasswordAuth()
217 {
218 {
219 std::optional<Database::IdType> userId {processAuthToken(environment())};
220 if (userId)
221 {
222 LMS_LOG(UI, DEBUG) << "User authenticated using Auth token!";
223 _authenticatedUser = {*userId, false};
224 onUserLoggedIn();
225 return;
226 }
227 }
228
229 setTheme();
230
231 // If here is no account in the database, launch the first connection wizard
232 bool firstConnection {};
233 {
234 auto transaction {getDbSession().createSharedTransaction()};
235 firstConnection = Database::User::getCount(getDbSession()) == 0;
236 }
237
238 LMS_LOG(UI, DEBUG) << "Creating root widget. First connection = " << firstConnection;
239
240 if (firstConnection && Service<::Auth::IPasswordService>::get()->canSetPasswords())
241 {
242 root()->addWidget(std::make_unique<InitWizardView>());
243 }
244 else
245 {
246 Auth* auth {root()->addNew<Auth>()};
247 auth->userLoggedIn.connect(this, [this](Database::IdType userId)
248 {
249 _authenticatedUser = {userId, true};
250 onUserLoggedIn();
251 });
252 }
253 }
254
255 void
setTheme()256 LmsApplication::setTheme()
257 {
258 Database::User::UITheme theme {Database::User::defaultUITheme};
259 {
260 auto transaction {getDbSession().createSharedTransaction()};
261 if (const auto user {getUser()})
262 theme = user->getUITheme();
263 }
264
265 WApplication::setTheme(std::make_unique<LmsTheme>(theme));
266 }
267
268 void
finalize()269 LmsApplication::finalize()
270 {
271 if (_authenticatedUser)
272 _appManager.unregisterApplication(*this);
273
274 preQuit().emit();
275 }
276
277 Wt::WLink
createArtistLink(Database::Artist::pointer artist)278 LmsApplication::createArtistLink(Database::Artist::pointer artist)
279 {
280 return Wt::WLink {Wt::LinkType::InternalPath, "/artist/" + std::to_string(artist.id())};
281 }
282
283 std::unique_ptr<Wt::WAnchor>
createArtistAnchor(Database::Artist::pointer artist,bool addText)284 LmsApplication::createArtistAnchor(Database::Artist::pointer artist, bool addText)
285 {
286 auto res = std::make_unique<Wt::WAnchor>(createArtistLink(artist));
287
288 if (addText)
289 {
290 res->setTextFormat(Wt::TextFormat::Plain);
291 res->setText(Wt::WString::fromUTF8(artist->getName()));
292 res->setToolTip(Wt::WString::fromUTF8(artist->getName()), Wt::TextFormat::Plain);
293 }
294
295 return res;
296 }
297
298 Wt::WLink
createReleaseLink(Database::Release::pointer release)299 LmsApplication::createReleaseLink(Database::Release::pointer release)
300 {
301 return Wt::WLink {Wt::LinkType::InternalPath, "/release/" + std::to_string(release.id())};
302 }
303
304 std::unique_ptr<Wt::WAnchor>
createReleaseAnchor(Database::Release::pointer release,bool addText)305 LmsApplication::createReleaseAnchor(Database::Release::pointer release, bool addText)
306 {
307 auto res = std::make_unique<Wt::WAnchor>(createReleaseLink(release));
308
309 if (addText)
310 {
311 res->setWordWrap(false);
312 res->setTextFormat(Wt::TextFormat::Plain);
313 res->setText(Wt::WString::fromUTF8(release->getName()));
314 res->setToolTip(Wt::WString::fromUTF8(release->getName()), Wt::TextFormat::Plain);
315 }
316
317 return res;
318 }
319
320 std::unique_ptr<Wt::WText>
createCluster(Database::Cluster::pointer cluster,bool canDelete)321 LmsApplication::createCluster(Database::Cluster::pointer cluster, bool canDelete)
322 {
323 auto getStyleClass = [](const Database::Cluster::pointer cluster)
324 {
325 switch (cluster->getType().id() % 6)
326 {
327 case 0: return "label-info";
328 case 1: return "label-warning";
329 case 2: return "label-primary";
330 case 3: return "label-default";
331 case 4: return "label-success";
332 case 5: return "label-danger";
333 }
334 return "label-default";
335 };
336
337 const std::string styleClass {getStyleClass(cluster)};
338 auto res {std::make_unique<Wt::WText>(std::string {} + (canDelete ? "<i class=\"fa fa-times-circle\"></i> " : "") + Wt::WString::fromUTF8(cluster->getName()), Wt::TextFormat::UnsafeXHTML)};
339
340 res->setStyleClass("Lms-cluster label " + styleClass);
341 res->setToolTip(cluster->getType()->getName(), Wt::TextFormat::Plain);
342 res->setInline(true);
343
344 return res;
345 }
346
347 Wt::WPopupMenu*
createPopupMenu()348 LmsApplication::createPopupMenu()
349 {
350 _popupMenu = std::make_unique<Wt::WPopupMenu>();
351 return _popupMenu.get();
352 }
353
354 void
handleException(LmsApplicationException & e)355 LmsApplication::handleException(LmsApplicationException& e)
356 {
357 root()->clear();
358 Wt::WTemplate* t {root()->addNew<Wt::WTemplate>(Wt::WString::tr("Lms.Error.template"))};
359 t->addFunction("tr", &Wt::WTemplate::Functions::tr);
360
361 t->bindString("error", e.what(), Wt::TextFormat::Plain);
362 Wt::WPushButton* btn {t->bindNew<Wt::WPushButton>("btn-go-home", Wt::WString::tr("Lms.Error.go-home"))};
363 btn->clicked().connect([this]()
364 {
365 redirect(defaultPath);
366 });
367 }
368
369 void
goHomeAndQuit()370 LmsApplication::goHomeAndQuit()
371 {
372 WApplication::quit("");
373 redirect(".");
374 }
375
376 enum IdxRoot
377 {
378 IdxExplore = 0,
379 IdxPlayQueue,
380 IdxSettings,
381 IdxAdminDatabase,
382 IdxAdminUsers,
383 IdxAdminUser,
384 };
385
386 static
387 void
handlePathChange(Wt::WStackedWidget & stack,bool isAdmin)388 handlePathChange(Wt::WStackedWidget& stack, bool isAdmin)
389 {
390 static const struct
391 {
392 std::string path;
393 int index;
394 bool admin;
395 } views[] =
396 {
397 { "/artists", IdxExplore, false },
398 { "/artist", IdxExplore, false },
399 { "/releases", IdxExplore, false },
400 { "/release", IdxExplore, false },
401 { "/search", IdxExplore, false },
402 { "/tracks", IdxExplore, false },
403 { "/playqueue", IdxPlayQueue, false },
404 { "/settings", IdxSettings, false },
405 { "/admin/database", IdxAdminDatabase, true },
406 { "/admin/users", IdxAdminUsers, true },
407 { "/admin/user", IdxAdminUser, true },
408 };
409
410 LMS_LOG(UI, DEBUG) << "Internal path changed to '" << wApp->internalPath() << "'";
411
412 LmsApp->doJavaScript(R"($('.navbar-nav li.active').removeClass('active'); $('.navbar-nav a[href="' + location.pathname + '"]').closest('li').addClass('active');)");
413
414 for (const auto& view : views)
415 {
416 if (wApp->internalPathMatches(view.path))
417 {
418 if (view.admin && !isAdmin)
419 break;
420
421 stack.setCurrentIndex(view.index);
422 return;
423 }
424 }
425
426 wApp->setInternalPath(defaultPath, true);
427 }
428
429 void
logoutUser()430 LmsApplication::logoutUser()
431 {
432 {
433 auto transaction {getDbSession().createUniqueTransaction()};
434 getUser().modify()->clearAuthTokens();
435 }
436
437 LMS_LOG(UI, INFO) << "User '" << getUserLoginName() << " 'logged out";
438 goHomeAndQuit();
439 }
440
441 void
onUserLoggedIn()442 LmsApplication::onUserLoggedIn()
443 {
444 setTheme();
445 root()->clear();
446
447 LMS_LOG(UI, INFO) << "User '" << getUserLoginName() << "' logged in from '" << environment().clientAddress() << "', user agent = " << environment().userAgent();
448
449 _appManager.registerApplication(*this);
450 _appManager.applicationRegistered.connect(this, [this] (LmsApplication& otherApplication)
451 {
452 // Only one active session by user
453 if (otherApplication.getUserId() == getUserId())
454 {
455 if (!LmsApp->isUserDemo())
456 {
457 quit(Wt::WString::tr("Lms.quit-other-session"));
458 }
459 }
460 });
461
462 createHome();
463 }
464
465 void
createHome()466 LmsApplication::createHome()
467 {
468 _coverResource = std::make_shared<CoverResource>();
469
470 declareJavaScriptFunction("onLoadCover", "function(id) { id.className += \" Lms-cover-loaded\"}");
471 doJavaScript("$('body').tooltip({ selector: '[data-toggle=\"tooltip\"]'})");
472
473 Wt::WTemplate* main {root()->addWidget(std::make_unique<Wt::WTemplate>(Wt::WString::tr("Lms.template")))};
474
475 main->addFunction("tr", &Wt::WTemplate::Functions::tr);
476
477 // MediaPlayer
478 _mediaPlayer = main->bindNew<MediaPlayer>("player");
479
480 main->bindNew<Wt::WAnchor>("title", Wt::WLink {Wt::LinkType::InternalPath, defaultPath}, "LMS");
481 main->bindNew<Wt::WAnchor>("artists", Wt::WLink {Wt::LinkType::InternalPath, "/artists"}, Wt::WString::tr("Lms.Explore.artists"));
482 main->bindNew<Wt::WAnchor>("releases", Wt::WLink {Wt::LinkType::InternalPath, "/releases"}, Wt::WString::tr("Lms.Explore.releases"));
483 main->bindNew<Wt::WAnchor>("tracks", Wt::WLink {Wt::LinkType::InternalPath, "/tracks"}, Wt::WString::tr("Lms.Explore.tracks"));
484
485 Filters* filters {main->bindNew<Filters>("filters")};
486 main->bindNew<Wt::WAnchor>("playqueue", Wt::WLink {Wt::LinkType::InternalPath, "/playqueue"}, Wt::WString::tr("Lms.PlayQueue.playqueue"));
487 main->bindString("username", getUserLoginName(), Wt::TextFormat::Plain);
488 main->bindNew<Wt::WAnchor>("settings", Wt::WLink {Wt::LinkType::InternalPath, "/settings"}, Wt::WString::tr("Lms.Settings.menu-settings"));
489
490 {
491 Wt::WAnchor* logout {main->bindNew<Wt::WAnchor>("logout")};
492 logout->setText(Wt::WString::tr("Lms.logout"));
493 logout->clicked().connect(this, &LmsApplication::logoutUser);
494 }
495
496 Wt::WLineEdit* searchEdit {main->bindNew<Wt::WLineEdit>("search")};
497 searchEdit->setPlaceholderText(Wt::WString::tr("Lms.Explore.Search.search-placeholder"));
498
499 if (isUserAdmin())
500 {
501 main->setCondition("if-is-admin", true);
502 main->bindNew<Wt::WAnchor>("database", Wt::WLink {Wt::LinkType::InternalPath, "/admin/database"}, Wt::WString::tr("Lms.Admin.Database.menu-database"));
503 main->bindNew<Wt::WAnchor>("users", Wt::WLink {Wt::LinkType::InternalPath, "/admin/users"}, Wt::WString::tr("Lms.Admin.Users.menu-users"));
504 }
505
506 // Contents
507 // Order is important in mainStack, see IdxRoot!
508 Wt::WStackedWidget* mainStack {main->bindNew<Wt::WStackedWidget>("contents")};
509 mainStack->setAttributeValue("style", "overflow-x:visible;overflow-y:visible;");
510
511 Explore* explore {mainStack->addNew<Explore>(filters)};
512 _playQueue = mainStack->addNew<PlayQueue>();
513 mainStack->addNew<SettingsView>();
514
515 searchEdit->enterPressed().connect([=]
516 {
517 setInternalPath("/search", true);
518 });
519
520 searchEdit->textInput().connect([=]
521 {
522 setInternalPath("/search", true);
523 explore->search(searchEdit->text());
524 });
525
526 // Admin stuff
527 if (isUserAdmin())
528 {
529 mainStack->addNew<DatabaseSettingsView>();
530 mainStack->addNew<UsersView>();
531 mainStack->addNew<UserView>();
532 }
533
534 explore->tracksAction.connect([this] (PlayQueueAction action, const std::vector<Database::IdType>& trackIds)
535 {
536 _playQueue->processTracks(action, trackIds);
537 });
538
539 // Events from MediaPlayer
540 _mediaPlayer->playNext.connect([this]
541 {
542 _playQueue->playNext();
543 });
544 _mediaPlayer->playPrevious.connect([this]
545 {
546 _playQueue->playPrevious();
547 });
548
549 _mediaPlayer->scrobbleListenNow.connect([this](Database::IdType trackId)
550 {
551 LMS_LOG(UI, DEBUG) << "Received ScrobbleListenNow from player for trackId = " << trackId;
552 const Scrobbling::Listen listen {getUserId(), trackId};
553 Service<Scrobbling::IScrobbling>::get()->listenStarted(listen);
554 });
555 _mediaPlayer->scrobbleListenFinished.connect([this](Database::IdType trackId, unsigned durationMs)
556 {
557 LMS_LOG(UI, DEBUG) << "Received ScrobbleListenFinished from player for trackId = " << trackId << ", duration = " << (durationMs / 1000) << "s";
558 const std::chrono::milliseconds duration {durationMs};
559 const Scrobbling::Listen listen {getUserId(), trackId};
560 Service<Scrobbling::IScrobbling>::get()->listenFinished(listen, std::chrono::duration_cast<std::chrono::seconds>(duration));
561 });
562
563 _mediaPlayer->playbackEnded.connect([this]
564 {
565 _playQueue->playNext();
566 });
567
568 _playQueue->trackSelected.connect([this] (Database::IdType trackId, bool play, float replayGain)
569 {
570 _mediaPlayer->loadTrack(trackId, play, replayGain);
571 });
572
573 _playQueue->trackUnselected.connect([this]
574 {
575 _mediaPlayer->stop();
576 });
577
578 if (isUserAdmin())
579 {
580 _scannerEvents.scanComplete.connect([=] (const Scanner::ScanStats& stats)
581 {
582 notifyMsg(MsgType::Info, Wt::WString::tr("Lms.Admin.Database.scan-complete")
583 .arg(static_cast<unsigned>(stats.nbFiles()))
584 .arg(static_cast<unsigned>(stats.additions))
585 .arg(static_cast<unsigned>(stats.updates))
586 .arg(static_cast<unsigned>(stats.deletions))
587 .arg(static_cast<unsigned>(stats.duplicates.size()))
588 .arg(static_cast<unsigned>(stats.errors.size())));
589 });
590 }
591
592 internalPathChanged().connect([=]
593 {
594 handlePathChange(*mainStack, isUserAdmin());
595 });
596
597 handlePathChange(*mainStack, isUserAdmin());
598 }
599
600 void
notify(const Wt::WEvent & event)601 LmsApplication::notify(const Wt::WEvent& event)
602 {
603 try
604 {
605 WApplication::notify(event);
606 }
607 catch (LmsApplicationException& e)
608 {
609 LMS_LOG(UI, WARNING) << "Caught a LmsApplication exception: " << e.what();
610 handleException(e);
611 }
612 catch (std::exception& e)
613 {
614 LMS_LOG(UI, ERROR) << "Caught exception: " << e.what();
615 throw LmsException {"Internal error"}; // Do not put details here at it may appear on the user rendered html
616 }
617 }
618
msgTypeToString(LmsApplication::MsgType type)619 static std::string msgTypeToString(LmsApplication::MsgType type)
620 {
621 switch(type)
622 {
623 case LmsApplication::MsgType::Success: return "success";
624 case LmsApplication::MsgType::Info: return "info";
625 case LmsApplication::MsgType::Warning: return "warning";
626 case LmsApplication::MsgType::Danger: return "danger";
627 }
628 return "";
629 }
630
631 void
post(std::function<void ()> func)632 LmsApplication::post(std::function<void()> func)
633 {
634 Wt::WServer::instance()->post(LmsApp->sessionId(), std::move(func));
635 }
636
637
638 void
notifyMsg(MsgType type,const Wt::WString & message,std::chrono::milliseconds duration)639 LmsApplication::notifyMsg(MsgType type, const Wt::WString& message, std::chrono::milliseconds duration)
640 {
641 LMS_LOG(UI, INFO) << "Notifying message '" << message.toUTF8() << "' of type '" << msgTypeToString(type) << "'";
642
643 std::ostringstream oss;
644
645 oss << "$.notify({"
646 "message: '" << StringUtils::jsEscape(message.toUTF8()) << "'"
647 "},{"
648 "type: '" << msgTypeToString(type) << "',"
649 "placement: {from: 'bottom', align: 'right'},"
650 "timer: 250,"
651 "offset: {x: 20, y: 80},"
652 "delay: " << duration.count() << ""
653 "});";
654
655 LmsApp->doJavaScript(oss.str());
656 }
657
658
659 } // namespace UserInterface
660
661
662