add web internet radio player
This commit is contained in:
@@ -34,6 +34,9 @@
|
||||
<li class="nav-item">
|
||||
${podcasts class="nav-link"}
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
${radio class="nav-link"}
|
||||
</li>
|
||||
</ul>
|
||||
${filters class="d-flex align-items-center me-auto mb-2 mb-md-0"}
|
||||
<div class="navbar-nav align-items-md-center">
|
||||
|
||||
@@ -371,6 +371,13 @@
|
||||
|
||||
<!--Podcasts-->
|
||||
<message id="Lms.Podcasts.podcasts">Podcasts</message>
|
||||
<message id="Lms.Radio.radio">Radio</message>
|
||||
<message id="Lms.Radio.description">Stream configured internet radio stations through the main player.</message>
|
||||
<message id="Lms.Radio.play"><i class="fa fa-play" aria-hidden="true"></i> Play</message>
|
||||
<message id="Lms.Radio.play-station">Play {1}</message>
|
||||
<message id="Lms.Radio.homepage">Station website</message>
|
||||
<message id="Lms.Radio.live">Live radio</message>
|
||||
<message id="Lms.Radio.empty">No internet radio stations are configured.</message>
|
||||
<message id="Lms.Podcasts.empty">No podcast feeds have been added yet.</message>
|
||||
<message id="Lms.Podcasts.back">← All podcasts</message>
|
||||
<message id="Lms.Podcasts.no-episodes">No episodes have been discovered yet.</message>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<messages>
|
||||
|
||||
<message id="Lms.Radio.template">
|
||||
<legend class="mb-3">${tr:Lms.Radio.radio}</legend>
|
||||
<p class="text-body-secondary">${tr:Lms.Radio.description}</p>
|
||||
${stations class="d-grid gap-2"}
|
||||
</message>
|
||||
|
||||
<message id="Lms.Radio.template.station">
|
||||
<div class="card w-100">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="flex-fill min-width-0">
|
||||
<h5 class="card-title text-truncate mb-1">${name}</h5>
|
||||
${homepage class="small"}
|
||||
</div>
|
||||
${play-btn class="btn btn-primary flex-shrink-0"}
|
||||
</div>
|
||||
</div>
|
||||
</message>
|
||||
|
||||
</messages>
|
||||
@@ -28,6 +28,7 @@ class LMSMediaPlayer {
|
||||
#queueEnabled;
|
||||
#transcodingEnabled;
|
||||
#duration;
|
||||
#isLive;
|
||||
#audioNativeSrc;
|
||||
#audioTranscodingSrc;
|
||||
#settings;
|
||||
@@ -47,6 +48,7 @@ class LMSMediaPlayer {
|
||||
this.#queueEnabled = true;
|
||||
this.#transcodingEnabled = true;
|
||||
this.#duration = 0;
|
||||
this.#isLive = false;
|
||||
this.#settings = {};
|
||||
this.#playedDuration = 0;
|
||||
this.#lastStartPlaying = null;
|
||||
@@ -94,8 +96,8 @@ class LMSMediaPlayer {
|
||||
this.#elems.audio.addEventListener("waiting", this.#pauseTimer.bind(this));
|
||||
|
||||
this.#elems.audio.addEventListener("timeupdate", () => {
|
||||
this.#elems.progress.style.width = "" + ((this.#offset + this.#elems.audio.currentTime) / this.#duration) * 100 + "%";
|
||||
this.#elems.curtime.innerHTML = this.#durationToString(this.#offset + this.#elems.audio.currentTime);
|
||||
this.#elems.progress.style.width = this.#isLive ? "0%" : "" + ((this.#offset + this.#elems.audio.currentTime) / this.#duration) * 100 + "%";
|
||||
this.#elems.curtime.innerHTML = this.#isLive ? "LIVE" : this.#durationToString(this.#offset + this.#elems.audio.currentTime);
|
||||
});
|
||||
|
||||
this.#elems.audio.addEventListener("ended", () => {
|
||||
@@ -362,6 +364,8 @@ class LMSMediaPlayer {
|
||||
}
|
||||
|
||||
#seekTo(seekTime) {
|
||||
if (this.#isLive)
|
||||
return;
|
||||
this.#initAudioCtx();
|
||||
let mode = this.#getAudioMode();
|
||||
if (!mode)
|
||||
@@ -402,7 +406,7 @@ class LMSMediaPlayer {
|
||||
}
|
||||
|
||||
#updateMediaSessionState() {
|
||||
if ("mediaSession" in navigator) {
|
||||
if ("mediaSession" in navigator && !this.#isLive && this.#duration > 0) {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration: this.#duration,
|
||||
playbackRate: 1,
|
||||
@@ -462,10 +466,12 @@ class LMSMediaPlayer {
|
||||
this.#elems.next.disabled = !this.#queueEnabled;
|
||||
this.#offset = 0;
|
||||
this.#duration = params.duration;
|
||||
this.#isLive = params.isLive ?? false;
|
||||
this.#audioNativeSrc = params.nativeResource;
|
||||
this.#audioTranscodingSrc = params.transcodingResource + "&bitrate=" + this.#settings.transcoding.bitrate + "&format=" + this.#settings.transcoding.format;
|
||||
|
||||
this.#elems.seek.max = this.#duration;
|
||||
this.#elems.seek.disabled = this.#isLive;
|
||||
|
||||
this.#removeAudioSources();
|
||||
// ! order is important
|
||||
@@ -477,8 +483,8 @@ class LMSMediaPlayer {
|
||||
}
|
||||
this.#elems.audio.load();
|
||||
|
||||
this.#elems.curtime.innerHTML = this.#durationToString(this.#offset);
|
||||
this.#elems.duration.innerHTML = this.#durationToString(this.#duration);
|
||||
this.#elems.curtime.innerHTML = this.#isLive ? "LIVE" : this.#durationToString(this.#offset);
|
||||
this.#elems.duration.innerHTML = this.#isLive ? "" : this.#durationToString(this.#duration);
|
||||
|
||||
if (!this.#audioIsInit) {
|
||||
this.#pendingTrackParameters = params;
|
||||
|
||||
@@ -12,6 +12,7 @@ add_executable(lms
|
||||
ui/NotificationContainer.cpp
|
||||
ui/PlayQueue.cpp
|
||||
ui/PodcastsView.cpp
|
||||
ui/RadioView.cpp
|
||||
ui/settings/AudioSettingsView.cpp
|
||||
ui/settings/PasswordSettingsView.cpp
|
||||
ui/settings/ServicesSettingsView.cpp
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
#include "NotificationContainer.hpp"
|
||||
#include "PlayQueue.hpp"
|
||||
#include "PodcastsView.hpp"
|
||||
#include "RadioView.hpp"
|
||||
#include "admin/About.hpp"
|
||||
#include "admin/AdminView.hpp"
|
||||
#include "admin/InitWizardView.hpp"
|
||||
@@ -102,6 +103,7 @@ namespace lms::ui
|
||||
res->use(appRoot + "notifications");
|
||||
res->use(appRoot + "playqueue");
|
||||
res->use(appRoot + "podcasts");
|
||||
res->use(appRoot + "radio");
|
||||
res->use(appRoot + "release");
|
||||
res->use(appRoot + "releases");
|
||||
res->use(appRoot + "settings-audio");
|
||||
@@ -417,6 +419,7 @@ namespace lms::ui
|
||||
navbar->bindNew<Wt::WAnchor>("tracks", Wt::WLink{ Wt::LinkType::InternalPath, "/tracks" }, Wt::WString::tr("Lms.Explore.tracks"));
|
||||
navbar->bindNew<Wt::WAnchor>("tracklists", Wt::WLink{ Wt::LinkType::InternalPath, "/tracklists" }, Wt::WString::tr("Lms.Explore.tracklists"));
|
||||
navbar->bindNew<Wt::WAnchor>("podcasts", Wt::WLink{ Wt::LinkType::InternalPath, "/podcasts" }, Wt::WString::tr("Lms.Podcasts.podcasts"));
|
||||
navbar->bindNew<Wt::WAnchor>("radio", Wt::WLink{ Wt::LinkType::InternalPath, "/radio" }, Wt::WString::tr("Lms.Radio.radio"));
|
||||
|
||||
Filters* filters{ navbar->bindNew<Filters>("filters") };
|
||||
navbar->bindString("username", std::string{ getUserLoginName() }, Wt::TextFormat::Plain);
|
||||
@@ -472,6 +475,7 @@ namespace lms::ui
|
||||
|
||||
mainRouter->add<SettingsView>("/settings", std::nullopt);
|
||||
mainRouter->add<PodcastsView>("/podcasts", Wt::WString::tr("Lms.Podcasts.podcasts"));
|
||||
mainRouter->add<RadioView>("/radio", Wt::WString::tr("Lms.Radio.radio"));
|
||||
|
||||
if (getUserType() == db::UserType::ADMIN)
|
||||
mainRouter->add<AdminView>("/admin", std::nullopt);
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
#include "database/objects/Artist.hpp"
|
||||
#include "database/objects/Podcast.hpp"
|
||||
#include "database/objects/PodcastEpisode.hpp"
|
||||
#include "database/objects/InternetRadioStation.hpp"
|
||||
#include "database/objects/Release.hpp"
|
||||
#include "database/objects/Track.hpp"
|
||||
#include "database/objects/TrackList.hpp"
|
||||
@@ -400,6 +401,56 @@ namespace lms::ui
|
||||
_podcastEpisodeIdLoaded = episodeId;
|
||||
}
|
||||
|
||||
void MediaPlayer::loadInternetRadio(db::InternetRadioStationId stationId)
|
||||
{
|
||||
LMS_LOG(UI, DEBUG, "Playing internet radio station ID = " << stationId.toString());
|
||||
|
||||
std::ostringstream oss;
|
||||
{
|
||||
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
|
||||
const db::InternetRadioStation::pointer station{ db::InternetRadioStation::find(LmsApp->getDbSession(), stationId) };
|
||||
if (!station)
|
||||
return;
|
||||
|
||||
const std::string name{ station->getName() };
|
||||
const std::string streamUrl{ station->getStreamUrl() };
|
||||
const std::string homepageUrl{ station->getHomepageUrl() };
|
||||
oss
|
||||
<< "var params = {"
|
||||
<< " trackId: \"radio-" << stationId.toString() << "\","
|
||||
<< " nativeResource: \"" << core::stringUtils::jsEscape(streamUrl) << "\","
|
||||
<< " transcodingResource: \"\","
|
||||
<< " duration: 0,"
|
||||
<< " replayGain: 0,"
|
||||
<< " isLive: true,"
|
||||
<< " scrobbleEnabled: false,"
|
||||
<< " queueEnabled: false,"
|
||||
<< " transcodingEnabled: false,"
|
||||
<< " title: \"" << core::stringUtils::jsEscape(name) << "\","
|
||||
<< " artist: \"Internet Radio\","
|
||||
<< " release: \"\","
|
||||
<< " artwork: [{ src: \"" << LmsApp->getArtworkResource()->getDefaultArtworkUrl(ArtworkResource::DefaultArtworkType::Track) << "\", type: \"image/svg+xml\" }]"
|
||||
<< "};";
|
||||
oss << jsRef() + ".mediaplayer.loadTrack(params, true)";
|
||||
|
||||
_title->setTextFormat(Wt::TextFormat::Plain);
|
||||
_title->setText(Wt::WString::fromUTF8(name));
|
||||
_artists->clear();
|
||||
_artists->addNew<Wt::WText>(Wt::WString::tr("Lms.Radio.live"), Wt::TextFormat::Plain);
|
||||
_release->setTextFormat(Wt::TextFormat::Plain);
|
||||
_release->setText(homepageUrl.empty() ? Wt::WString{} : Wt::WString::tr("Lms.Radio.homepage"));
|
||||
_release->setLink(homepageUrl.empty() ? Wt::WLink{} : Wt::WLink{ homepageUrl });
|
||||
_release->setAttributeValue("target", "_blank");
|
||||
_release->setAttributeValue("rel", "noopener noreferrer");
|
||||
_separator->setText(homepageUrl.empty() ? "" : " — ");
|
||||
}
|
||||
|
||||
LMS_LOG(UI, DEBUG, "Running js = '" << oss.str() << "'");
|
||||
doJavaScript(oss.str());
|
||||
_trackIdLoaded.reset();
|
||||
_podcastEpisodeIdLoaded.reset();
|
||||
}
|
||||
|
||||
void MediaPlayer::stop()
|
||||
{
|
||||
doJavaScript(jsRef() + ".mediaplayer.stop()");
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
|
||||
#include "database/objects/TrackId.hpp"
|
||||
#include "database/objects/PodcastEpisodeId.hpp"
|
||||
#include "database/objects/InternetRadioStationId.hpp"
|
||||
#include "database/objects/Types.hpp"
|
||||
|
||||
namespace lms::ui
|
||||
@@ -99,6 +100,7 @@ namespace lms::ui
|
||||
|
||||
void loadTrack(db::TrackId trackId, bool play, float replayGain);
|
||||
void loadPodcastEpisode(db::PodcastEpisodeId episodeId, bool play = true);
|
||||
void loadInternetRadio(db::InternetRadioStationId stationId);
|
||||
void stop();
|
||||
|
||||
std::optional<Settings> getSettings() const { return _settings; }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*/
|
||||
|
||||
#include "RadioView.hpp"
|
||||
|
||||
#include <Wt/WAnchor.h>
|
||||
#include <Wt/WPushButton.h>
|
||||
#include <Wt/WTemplate.h>
|
||||
#include <Wt/WText.h>
|
||||
|
||||
#include "database/Session.hpp"
|
||||
#include "database/objects/InternetRadioStation.hpp"
|
||||
|
||||
#include "LmsApplication.hpp"
|
||||
#include "MediaPlayer.hpp"
|
||||
|
||||
namespace lms::ui
|
||||
{
|
||||
RadioView::RadioView()
|
||||
{
|
||||
auto* page{ addNew<Wt::WTemplate>(Wt::WString::tr("Lms.Radio.template")) };
|
||||
page->addFunction("tr", &Wt::WTemplate::Functions::tr);
|
||||
_stations = page->bindNew<Wt::WContainerWidget>("stations");
|
||||
|
||||
wApp->internalPathChanged().connect(this, [this] { refreshView(); });
|
||||
refreshView();
|
||||
}
|
||||
|
||||
void RadioView::refreshView()
|
||||
{
|
||||
if (!wApp->internalPathMatches("/radio"))
|
||||
return;
|
||||
|
||||
_stations->clear();
|
||||
auto transaction{ LmsApp->getDbSession().createReadTransaction() };
|
||||
db::InternetRadioStation::find(LmsApp->getDbSession(), [this](const db::InternetRadioStation::pointer& station) {
|
||||
auto* entry{ _stations->addNew<Wt::WTemplate>(Wt::WString::tr("Lms.Radio.template.station")) };
|
||||
entry->bindString("name", std::string{ station->getName() }, Wt::TextFormat::Plain);
|
||||
|
||||
if (!station->getHomepageUrl().empty())
|
||||
{
|
||||
auto* homepage{ entry->bindNew<Wt::WAnchor>("homepage", Wt::WLink{ std::string{ station->getHomepageUrl() } }, Wt::WString::tr("Lms.Radio.homepage")) };
|
||||
homepage->setAttributeValue("target", "_blank");
|
||||
homepage->setAttributeValue("rel", "noopener noreferrer");
|
||||
}
|
||||
else
|
||||
entry->bindEmpty("homepage");
|
||||
|
||||
const db::InternetRadioStationId stationId{ station->getId() };
|
||||
auto* playBtn{ entry->bindNew<Wt::WPushButton>("play-btn", Wt::WString::tr("Lms.Radio.play"), Wt::TextFormat::XHTML) };
|
||||
playBtn->setToolTip(Wt::WString::tr("Lms.Radio.play-station").arg(Wt::WString::fromUTF8(std::string{ station->getName() })));
|
||||
playBtn->clicked().connect([stationId] { LmsApp->getMediaPlayer().loadInternetRadio(stationId); });
|
||||
});
|
||||
|
||||
if (_stations->count() == 0)
|
||||
_stations->addNew<Wt::WText>(Wt::WString::tr("Lms.Radio.empty"))->setStyleClass("text-body-secondary py-3");
|
||||
}
|
||||
} // namespace lms::ui
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Emeric Poupon
|
||||
*
|
||||
* This file is part of LMS.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <Wt/WContainerWidget.h>
|
||||
|
||||
namespace lms::ui
|
||||
{
|
||||
class RadioView final : public Wt::WContainerWidget
|
||||
{
|
||||
public:
|
||||
RadioView();
|
||||
|
||||
private:
|
||||
void refreshView();
|
||||
|
||||
Wt::WContainerWidget* _stations{};
|
||||
};
|
||||
} // namespace lms::ui
|
||||
Reference in New Issue
Block a user