add web internet radio player
This commit is contained in:
@@ -34,6 +34,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
${podcasts class="nav-link"}
|
${podcasts class="nav-link"}
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
${radio class="nav-link"}
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
${filters class="d-flex align-items-center me-auto mb-2 mb-md-0"}
|
${filters class="d-flex align-items-center me-auto mb-2 mb-md-0"}
|
||||||
<div class="navbar-nav align-items-md-center">
|
<div class="navbar-nav align-items-md-center">
|
||||||
|
|||||||
@@ -371,6 +371,13 @@
|
|||||||
|
|
||||||
<!--Podcasts-->
|
<!--Podcasts-->
|
||||||
<message id="Lms.Podcasts.podcasts">Podcasts</message>
|
<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.empty">No podcast feeds have been added yet.</message>
|
||||||
<message id="Lms.Podcasts.back">← All podcasts</message>
|
<message id="Lms.Podcasts.back">← All podcasts</message>
|
||||||
<message id="Lms.Podcasts.no-episodes">No episodes have been discovered yet.</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;
|
#queueEnabled;
|
||||||
#transcodingEnabled;
|
#transcodingEnabled;
|
||||||
#duration;
|
#duration;
|
||||||
|
#isLive;
|
||||||
#audioNativeSrc;
|
#audioNativeSrc;
|
||||||
#audioTranscodingSrc;
|
#audioTranscodingSrc;
|
||||||
#settings;
|
#settings;
|
||||||
@@ -47,6 +48,7 @@ class LMSMediaPlayer {
|
|||||||
this.#queueEnabled = true;
|
this.#queueEnabled = true;
|
||||||
this.#transcodingEnabled = true;
|
this.#transcodingEnabled = true;
|
||||||
this.#duration = 0;
|
this.#duration = 0;
|
||||||
|
this.#isLive = false;
|
||||||
this.#settings = {};
|
this.#settings = {};
|
||||||
this.#playedDuration = 0;
|
this.#playedDuration = 0;
|
||||||
this.#lastStartPlaying = null;
|
this.#lastStartPlaying = null;
|
||||||
@@ -94,8 +96,8 @@ class LMSMediaPlayer {
|
|||||||
this.#elems.audio.addEventListener("waiting", this.#pauseTimer.bind(this));
|
this.#elems.audio.addEventListener("waiting", this.#pauseTimer.bind(this));
|
||||||
|
|
||||||
this.#elems.audio.addEventListener("timeupdate", () => {
|
this.#elems.audio.addEventListener("timeupdate", () => {
|
||||||
this.#elems.progress.style.width = "" + ((this.#offset + this.#elems.audio.currentTime) / this.#duration) * 100 + "%";
|
this.#elems.progress.style.width = this.#isLive ? "0%" : "" + ((this.#offset + this.#elems.audio.currentTime) / this.#duration) * 100 + "%";
|
||||||
this.#elems.curtime.innerHTML = this.#durationToString(this.#offset + this.#elems.audio.currentTime);
|
this.#elems.curtime.innerHTML = this.#isLive ? "LIVE" : this.#durationToString(this.#offset + this.#elems.audio.currentTime);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.#elems.audio.addEventListener("ended", () => {
|
this.#elems.audio.addEventListener("ended", () => {
|
||||||
@@ -362,6 +364,8 @@ class LMSMediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#seekTo(seekTime) {
|
#seekTo(seekTime) {
|
||||||
|
if (this.#isLive)
|
||||||
|
return;
|
||||||
this.#initAudioCtx();
|
this.#initAudioCtx();
|
||||||
let mode = this.#getAudioMode();
|
let mode = this.#getAudioMode();
|
||||||
if (!mode)
|
if (!mode)
|
||||||
@@ -402,7 +406,7 @@ class LMSMediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#updateMediaSessionState() {
|
#updateMediaSessionState() {
|
||||||
if ("mediaSession" in navigator) {
|
if ("mediaSession" in navigator && !this.#isLive && this.#duration > 0) {
|
||||||
navigator.mediaSession.setPositionState({
|
navigator.mediaSession.setPositionState({
|
||||||
duration: this.#duration,
|
duration: this.#duration,
|
||||||
playbackRate: 1,
|
playbackRate: 1,
|
||||||
@@ -462,10 +466,12 @@ class LMSMediaPlayer {
|
|||||||
this.#elems.next.disabled = !this.#queueEnabled;
|
this.#elems.next.disabled = !this.#queueEnabled;
|
||||||
this.#offset = 0;
|
this.#offset = 0;
|
||||||
this.#duration = params.duration;
|
this.#duration = params.duration;
|
||||||
|
this.#isLive = params.isLive ?? false;
|
||||||
this.#audioNativeSrc = params.nativeResource;
|
this.#audioNativeSrc = params.nativeResource;
|
||||||
this.#audioTranscodingSrc = params.transcodingResource + "&bitrate=" + this.#settings.transcoding.bitrate + "&format=" + this.#settings.transcoding.format;
|
this.#audioTranscodingSrc = params.transcodingResource + "&bitrate=" + this.#settings.transcoding.bitrate + "&format=" + this.#settings.transcoding.format;
|
||||||
|
|
||||||
this.#elems.seek.max = this.#duration;
|
this.#elems.seek.max = this.#duration;
|
||||||
|
this.#elems.seek.disabled = this.#isLive;
|
||||||
|
|
||||||
this.#removeAudioSources();
|
this.#removeAudioSources();
|
||||||
// ! order is important
|
// ! order is important
|
||||||
@@ -477,8 +483,8 @@ class LMSMediaPlayer {
|
|||||||
}
|
}
|
||||||
this.#elems.audio.load();
|
this.#elems.audio.load();
|
||||||
|
|
||||||
this.#elems.curtime.innerHTML = this.#durationToString(this.#offset);
|
this.#elems.curtime.innerHTML = this.#isLive ? "LIVE" : this.#durationToString(this.#offset);
|
||||||
this.#elems.duration.innerHTML = this.#durationToString(this.#duration);
|
this.#elems.duration.innerHTML = this.#isLive ? "" : this.#durationToString(this.#duration);
|
||||||
|
|
||||||
if (!this.#audioIsInit) {
|
if (!this.#audioIsInit) {
|
||||||
this.#pendingTrackParameters = params;
|
this.#pendingTrackParameters = params;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ add_executable(lms
|
|||||||
ui/NotificationContainer.cpp
|
ui/NotificationContainer.cpp
|
||||||
ui/PlayQueue.cpp
|
ui/PlayQueue.cpp
|
||||||
ui/PodcastsView.cpp
|
ui/PodcastsView.cpp
|
||||||
|
ui/RadioView.cpp
|
||||||
ui/settings/AudioSettingsView.cpp
|
ui/settings/AudioSettingsView.cpp
|
||||||
ui/settings/PasswordSettingsView.cpp
|
ui/settings/PasswordSettingsView.cpp
|
||||||
ui/settings/ServicesSettingsView.cpp
|
ui/settings/ServicesSettingsView.cpp
|
||||||
|
|||||||
@@ -55,6 +55,7 @@
|
|||||||
#include "NotificationContainer.hpp"
|
#include "NotificationContainer.hpp"
|
||||||
#include "PlayQueue.hpp"
|
#include "PlayQueue.hpp"
|
||||||
#include "PodcastsView.hpp"
|
#include "PodcastsView.hpp"
|
||||||
|
#include "RadioView.hpp"
|
||||||
#include "admin/About.hpp"
|
#include "admin/About.hpp"
|
||||||
#include "admin/AdminView.hpp"
|
#include "admin/AdminView.hpp"
|
||||||
#include "admin/InitWizardView.hpp"
|
#include "admin/InitWizardView.hpp"
|
||||||
@@ -102,6 +103,7 @@ namespace lms::ui
|
|||||||
res->use(appRoot + "notifications");
|
res->use(appRoot + "notifications");
|
||||||
res->use(appRoot + "playqueue");
|
res->use(appRoot + "playqueue");
|
||||||
res->use(appRoot + "podcasts");
|
res->use(appRoot + "podcasts");
|
||||||
|
res->use(appRoot + "radio");
|
||||||
res->use(appRoot + "release");
|
res->use(appRoot + "release");
|
||||||
res->use(appRoot + "releases");
|
res->use(appRoot + "releases");
|
||||||
res->use(appRoot + "settings-audio");
|
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>("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>("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>("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") };
|
Filters* filters{ navbar->bindNew<Filters>("filters") };
|
||||||
navbar->bindString("username", std::string{ getUserLoginName() }, Wt::TextFormat::Plain);
|
navbar->bindString("username", std::string{ getUserLoginName() }, Wt::TextFormat::Plain);
|
||||||
@@ -472,6 +475,7 @@ namespace lms::ui
|
|||||||
|
|
||||||
mainRouter->add<SettingsView>("/settings", std::nullopt);
|
mainRouter->add<SettingsView>("/settings", std::nullopt);
|
||||||
mainRouter->add<PodcastsView>("/podcasts", Wt::WString::tr("Lms.Podcasts.podcasts"));
|
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)
|
if (getUserType() == db::UserType::ADMIN)
|
||||||
mainRouter->add<AdminView>("/admin", std::nullopt);
|
mainRouter->add<AdminView>("/admin", std::nullopt);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
#include "database/objects/Artist.hpp"
|
#include "database/objects/Artist.hpp"
|
||||||
#include "database/objects/Podcast.hpp"
|
#include "database/objects/Podcast.hpp"
|
||||||
#include "database/objects/PodcastEpisode.hpp"
|
#include "database/objects/PodcastEpisode.hpp"
|
||||||
|
#include "database/objects/InternetRadioStation.hpp"
|
||||||
#include "database/objects/Release.hpp"
|
#include "database/objects/Release.hpp"
|
||||||
#include "database/objects/Track.hpp"
|
#include "database/objects/Track.hpp"
|
||||||
#include "database/objects/TrackList.hpp"
|
#include "database/objects/TrackList.hpp"
|
||||||
@@ -400,6 +401,56 @@ namespace lms::ui
|
|||||||
_podcastEpisodeIdLoaded = episodeId;
|
_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()
|
void MediaPlayer::stop()
|
||||||
{
|
{
|
||||||
doJavaScript(jsRef() + ".mediaplayer.stop()");
|
doJavaScript(jsRef() + ".mediaplayer.stop()");
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
|
|
||||||
#include "database/objects/TrackId.hpp"
|
#include "database/objects/TrackId.hpp"
|
||||||
#include "database/objects/PodcastEpisodeId.hpp"
|
#include "database/objects/PodcastEpisodeId.hpp"
|
||||||
|
#include "database/objects/InternetRadioStationId.hpp"
|
||||||
#include "database/objects/Types.hpp"
|
#include "database/objects/Types.hpp"
|
||||||
|
|
||||||
namespace lms::ui
|
namespace lms::ui
|
||||||
@@ -99,6 +100,7 @@ namespace lms::ui
|
|||||||
|
|
||||||
void loadTrack(db::TrackId trackId, bool play, float replayGain);
|
void loadTrack(db::TrackId trackId, bool play, float replayGain);
|
||||||
void loadPodcastEpisode(db::PodcastEpisodeId episodeId, bool play = true);
|
void loadPodcastEpisode(db::PodcastEpisodeId episodeId, bool play = true);
|
||||||
|
void loadInternetRadio(db::InternetRadioStationId stationId);
|
||||||
void stop();
|
void stop();
|
||||||
|
|
||||||
std::optional<Settings> getSettings() const { return _settings; }
|
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