diff --git a/approot/main.xml b/approot/main.xml
index 786d5211..1a59816d 100644
--- a/approot/main.xml
+++ b/approot/main.xml
@@ -34,6 +34,9 @@
diff --git a/approot/messages.xml b/approot/messages.xml
index cc86b666..fec2a695 100644
--- a/approot/messages.xml
+++ b/approot/messages.xml
@@ -371,6 +371,13 @@
Podcasts
+
Radio
+
Stream configured internet radio stations through the main player.
+
Play
+
Play {1}
+
Station website
+
Live radio
+
No internet radio stations are configured.
No podcast feeds have been added yet.
← All podcasts
No episodes have been discovered yet.
diff --git a/approot/radio.xml b/approot/radio.xml
new file mode 100644
index 00000000..fd624335
--- /dev/null
+++ b/approot/radio.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+ ${tr:Lms.Radio.description}
+ ${stations class="d-grid gap-2"}
+
+
+
+
+
+
+
${name}
+ ${homepage class="small"}
+
+ ${play-btn class="btn btn-primary flex-shrink-0"}
+
+
+
+
+
diff --git a/docroot/js/mediaplayer.js b/docroot/js/mediaplayer.js
index dbb4121e..60a205d2 100644
--- a/docroot/js/mediaplayer.js
+++ b/docroot/js/mediaplayer.js
@@ -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;
diff --git a/src/lms/CMakeLists.txt b/src/lms/CMakeLists.txt
index 6127a1f1..5163b9e9 100644
--- a/src/lms/CMakeLists.txt
+++ b/src/lms/CMakeLists.txt
@@ -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
diff --git a/src/lms/ui/LmsApplication.cpp b/src/lms/ui/LmsApplication.cpp
index bdbbd5e4..f4758d38 100644
--- a/src/lms/ui/LmsApplication.cpp
+++ b/src/lms/ui/LmsApplication.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
("tracks", Wt::WLink{ Wt::LinkType::InternalPath, "/tracks" }, Wt::WString::tr("Lms.Explore.tracks"));
navbar->bindNew("tracklists", Wt::WLink{ Wt::LinkType::InternalPath, "/tracklists" }, Wt::WString::tr("Lms.Explore.tracklists"));
navbar->bindNew("podcasts", Wt::WLink{ Wt::LinkType::InternalPath, "/podcasts" }, Wt::WString::tr("Lms.Podcasts.podcasts"));
+ navbar->bindNew("radio", Wt::WLink{ Wt::LinkType::InternalPath, "/radio" }, Wt::WString::tr("Lms.Radio.radio"));
Filters* filters{ navbar->bindNew("filters") };
navbar->bindString("username", std::string{ getUserLoginName() }, Wt::TextFormat::Plain);
@@ -472,6 +475,7 @@ namespace lms::ui
mainRouter->add("/settings", std::nullopt);
mainRouter->add("/podcasts", Wt::WString::tr("Lms.Podcasts.podcasts"));
+ mainRouter->add("/radio", Wt::WString::tr("Lms.Radio.radio"));
if (getUserType() == db::UserType::ADMIN)
mainRouter->add("/admin", std::nullopt);
diff --git a/src/lms/ui/MediaPlayer.cpp b/src/lms/ui/MediaPlayer.cpp
index f1a76468..58a484f3 100644
--- a/src/lms/ui/MediaPlayer.cpp
+++ b/src/lms/ui/MediaPlayer.cpp
@@ -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::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()");
diff --git a/src/lms/ui/MediaPlayer.hpp b/src/lms/ui/MediaPlayer.hpp
index 2a42853d..afe292d4 100644
--- a/src/lms/ui/MediaPlayer.hpp
+++ b/src/lms/ui/MediaPlayer.hpp
@@ -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 getSettings() const { return _settings; }
diff --git a/src/lms/ui/RadioView.cpp b/src/lms/ui/RadioView.cpp
new file mode 100644
index 00000000..e79a0877
--- /dev/null
+++ b/src/lms/ui/RadioView.cpp
@@ -0,0 +1,61 @@
+/*
+ * Copyright (C) 2026 Emeric Poupon
+ *
+ * This file is part of LMS.
+ */
+
+#include "RadioView.hpp"
+
+#include
+#include
+#include
+#include
+
+#include "database/Session.hpp"
+#include "database/objects/InternetRadioStation.hpp"
+
+#include "LmsApplication.hpp"
+#include "MediaPlayer.hpp"
+
+namespace lms::ui
+{
+ RadioView::RadioView()
+ {
+ auto* page{ addNew(Wt::WString::tr("Lms.Radio.template")) };
+ page->addFunction("tr", &Wt::WTemplate::Functions::tr);
+ _stations = page->bindNew("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::WString::tr("Lms.Radio.template.station")) };
+ entry->bindString("name", std::string{ station->getName() }, Wt::TextFormat::Plain);
+
+ if (!station->getHomepageUrl().empty())
+ {
+ auto* homepage{ entry->bindNew("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("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::WString::tr("Lms.Radio.empty"))->setStyleClass("text-body-secondary py-3");
+ }
+} // namespace lms::ui
diff --git a/src/lms/ui/RadioView.hpp b/src/lms/ui/RadioView.hpp
new file mode 100644
index 00000000..fc31250d
--- /dev/null
+++ b/src/lms/ui/RadioView.hpp
@@ -0,0 +1,23 @@
+/*
+ * Copyright (C) 2026 Emeric Poupon
+ *
+ * This file is part of LMS.
+ */
+
+#pragma once
+
+#include
+
+namespace lms::ui
+{
+ class RadioView final : public Wt::WContainerWidget
+ {
+ public:
+ RadioView();
+
+ private:
+ void refreshView();
+
+ Wt::WContainerWidget* _stations{};
+ };
+} // namespace lms::ui