diff --git a/include/livekit/remote_track_publication.h b/include/livekit/remote_track_publication.h index b6475612..65a9782d 100644 --- a/include/livekit/remote_track_publication.h +++ b/include/livekit/remote_track_publication.h @@ -16,6 +16,9 @@ #pragma once +#include + +#include "livekit/track.h" #include "livekit/track_publication.h" #include "livekit/visibility.h" @@ -32,12 +35,77 @@ class LIVEKIT_API RemoteTrackPublication : public TrackPublication { /// safe to accept proto::OwnedTrackPublication. explicit RemoteTrackPublication(const proto::OwnedTrackPublication& owned); + /// @brief Returns the locally recorded subscription state. + /// + /// @return True if the publication is marked as subscribed. bool subscribed() const noexcept { return subscribed_; } + /// @brief Changes the desired subscription state for this publication. + /// + /// @param subscribed True to subscribe; false to unsubscribe. + /// @throws std::runtime_error if the publication has an invalid FFI handle or + /// the FFI request fails. void setSubscribed(bool subscribed); + /// @brief Returns whether media delivery is enabled for this publication. + /// + /// @return True if media delivery is enabled. + bool enabled() const noexcept { return enabled_; } + + /// @brief Enables or disables media delivery for this publication. + /// + /// Disabling a subscribed publication reduces bandwidth without changing its + /// desired subscription state. + /// + /// @param enabled True to enable media delivery; false to disable it. + /// @return True if an update was sent; false if the state is unchanged or + /// the publication is not subscribed. + /// @throws std::runtime_error if the publication has an invalid FFI handle or + /// the FFI request fails. + bool setEnabled(bool enabled); + + /// @brief Returns the requested maximum simulcast quality. + /// + /// @return The requested quality, or @c VideoQuality::HIGH when dimensions + /// are preferred. + VideoQuality videoQuality() const noexcept { return video_quality_; } + + /// @brief Requests the maximum simulcast quality to receive. + /// + /// This overrides a previous call to setVideoDimensions(). + /// + /// @param quality Maximum acceptable simulcast quality. + /// @return True if an update was sent; false if the quality is unchanged or + /// the publication is not a subscribed, simulcasted video track. + /// @throws std::runtime_error if the publication has an invalid FFI handle or + /// the FFI request fails. + bool setVideoQuality(VideoQuality quality); + + /// @brief Requests the maximum video dimensions to receive for this publication. + /// + /// The server selects the closest available simulcast or scalable-video layer. + /// This overrides a previous call to setVideoQuality(). + /// Repeating the current dimensions does not send another request. + /// + /// @param width Requested maximum width in pixels. Must be greater than zero. + /// @param height Requested maximum height in pixels. Must be greater than zero. + /// @return True if an update was sent; false if the dimensions are unchanged + /// or the publication is not a subscribed video track. + /// @throws std::runtime_error if the publication has an invalid FFI handle or + /// the FFI request fails. + bool setVideoDimensions(std::uint32_t width, std::uint32_t height); + private: + enum class VideoPreference { + DEFAULT, + DIMENSIONS, + QUALITY, + }; + bool subscribed_{false}; + bool enabled_{true}; + VideoPreference video_preference_{VideoPreference::DEFAULT}; + VideoQuality video_quality_{VideoQuality::HIGH}; }; } // namespace livekit diff --git a/include/livekit/track.h b/include/livekit/track.h index 5fed6fe6..87ce4347 100644 --- a/include/livekit/track.h +++ b/include/livekit/track.h @@ -21,7 +21,6 @@ #include #include #include -#include #include #include "livekit/ffi_handle.h" @@ -55,6 +54,13 @@ enum class StreamState { STATE_PAUSED = 2, }; +/// @brief Maximum receive quality requested for a remote video track. +enum class VideoQuality { + LOW, + MEDIUM, + HIGH, +}; + /// @brief Optional audio processing or encoding feature advertised for a track. enum class AudioTrackFeature { TF_STEREO = 0, diff --git a/src/remote_track_publication.cpp b/src/remote_track_publication.cpp index 03e9f94f..e1f02b7d 100644 --- a/src/remote_track_publication.cpp +++ b/src/remote_track_publication.cpp @@ -18,6 +18,7 @@ #include "ffi.pb.h" #include "ffi_client.h" +#include "lk_log.h" #include "track_proto_converter.h" namespace livekit { @@ -47,4 +48,120 @@ void RemoteTrackPublication::setSubscribed(bool subscribed) { subscribed_ = subscribed; } +bool RemoteTrackPublication::setEnabled(bool enabled) { + if (!subscribed_ || track() == nullptr) { + LK_LOG_WARN("RemoteTrackPublication::setEnabled ignored for unsubscribed publication {}", sid()); + return false; + } + if (enabled_ == enabled) { + return false; + } + if (ffiHandleId() == 0) { + throw std::runtime_error("RemoteTrackPublication::setEnabled: invalid FFI handle"); + } + + proto::FfiRequest req; + auto* msg = req.mutable_enable_remote_track_publication(); + msg->set_track_publication_handle(static_cast(ffiHandleId())); + msg->set_enabled(enabled); + + const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); + if (!resp.has_enable_remote_track_publication()) { + throw std::runtime_error("RemoteTrackPublication::setEnabled: FFI response missing enabled update result"); + } + + enabled_ = enabled; + return true; +} + +bool RemoteTrackPublication::setVideoQuality(VideoQuality quality) { + if (kind() != TrackKind::KIND_VIDEO) { + LK_LOG_WARN("RemoteTrackPublication::setVideoQuality ignored for non-video publication {}", sid()); + return false; + } + if (!simulcasted()) { + LK_LOG_WARN("RemoteTrackPublication::setVideoQuality ignored for non-simulcast publication {}", sid()); + return false; + } + if (!subscribed_ || track() == nullptr) { + LK_LOG_WARN("RemoteTrackPublication::setVideoQuality ignored for unsubscribed publication {}", sid()); + return false; + } + if (video_preference_ == VideoPreference::QUALITY && video_quality_ == quality) { + return false; + } + + proto::VideoQuality proto_quality; + switch (quality) { + case VideoQuality::LOW: + proto_quality = proto::VideoQuality::VIDEO_QUALITY_LOW; + break; + case VideoQuality::MEDIUM: + proto_quality = proto::VideoQuality::VIDEO_QUALITY_MEDIUM; + break; + case VideoQuality::HIGH: + proto_quality = proto::VideoQuality::VIDEO_QUALITY_HIGH; + break; + default: + LK_LOG_WARN("RemoteTrackPublication::setVideoQuality ignored invalid quality for publication {}", sid()); + return false; + } + if (ffiHandleId() == 0) { + throw std::runtime_error("RemoteTrackPublication::setVideoQuality: invalid FFI handle"); + } + + proto::FfiRequest req; + auto* msg = req.mutable_set_remote_track_publication_quality(); + msg->set_track_publication_handle(static_cast(ffiHandleId())); + msg->set_quality(proto_quality); + + const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); + if (!resp.has_set_remote_track_publication_quality()) { + throw std::runtime_error("RemoteTrackPublication::setVideoQuality: FFI response missing quality update result"); + } + + video_preference_ = VideoPreference::QUALITY; + video_quality_ = quality; + return true; +} + +bool RemoteTrackPublication::setVideoDimensions(std::uint32_t width, std::uint32_t height) { + if (kind() != TrackKind::KIND_VIDEO) { + LK_LOG_WARN("RemoteTrackPublication::setVideoDimensions ignored for non-video publication {}", sid()); + return false; + } + if (width == 0 || height == 0) { + LK_LOG_WARN("RemoteTrackPublication::setVideoDimensions requires non-zero dimensions for publication {}", sid()); + return false; + } + if (!subscribed_ || track() == nullptr) { + LK_LOG_WARN("RemoteTrackPublication::setVideoDimensions ignored for unsubscribed publication {}", sid()); + return false; + } + if (video_preference_ == VideoPreference::DIMENSIONS && width_ == width && height_ == height) { + return false; + } + if (ffiHandleId() == 0) { + throw std::runtime_error("RemoteTrackPublication::setVideoDimensions: invalid FFI handle"); + } + + proto::FfiRequest req; + auto* msg = req.mutable_update_remote_track_publication_dimension(); + msg->set_track_publication_handle(static_cast(ffiHandleId())); + msg->set_width(width); + msg->set_height(height); + + const proto::FfiResponse resp = FfiClient::instance().sendRequest(req); + if (!resp.has_update_remote_track_publication_dimension()) { + throw std::runtime_error( + "RemoteTrackPublication::setVideoDimensions: FFI response missing dimension update result"); + } + + width_ = width; + height_ = height; + video_preference_ = VideoPreference::DIMENSIONS; + video_quality_ = VideoQuality::HIGH; + return true; +} + } // namespace livekit diff --git a/src/tests/integration/test_remote_track_publication.cpp b/src/tests/integration/test_remote_track_publication.cpp new file mode 100644 index 00000000..80711bd1 --- /dev/null +++ b/src/tests/integration/test_remote_track_publication.cpp @@ -0,0 +1,109 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include +#include +#include +#include +#include + +#include "tests/common/test_common.h" + +namespace livekit::test { + +class RemoteTrackPublicationServerTest : public LiveKitTestBase {}; + +TEST_F(RemoteTrackPublicationServerTest, PublicationControlsRoundTripAndDeduplicate) { + failIfNotConfigured(); + + Room publisher_room; + Room subscriber_room; + const RoomOptions options; + + ASSERT_TRUE(subscriber_room.connect(config_.url, config_.token_b, options)); + ASSERT_TRUE(publisher_room.connect(config_.url, config_.token_a, options)); + + const std::string publisher_identity = lockLocalParticipant(publisher_room)->identity(); + ASSERT_TRUE(waitForParticipant(&subscriber_room, publisher_identity, 10s)); + + constexpr std::uint32_t kSourceWidth = 640; + constexpr std::uint32_t kSourceHeight = 360; + constexpr std::uint32_t kRequestedWidth = 320; + constexpr std::uint32_t kRequestedHeight = 180; + const std::string track_name = "remote-dimension-update"; + + auto source = std::make_shared(kSourceWidth, kSourceHeight); + auto track = LocalVideoTrack::createLocalVideoTrack(track_name, source); + TrackPublishOptions publish_options; + publish_options.source = TrackSource::SOURCE_CAMERA; + publish_options.simulcast = true; + ASSERT_NO_THROW(lockLocalParticipant(publisher_room)->publishTrack(track, publish_options)); + + std::shared_ptr publication; + const auto subscription_deadline = std::chrono::steady_clock::now() + 10s; + while (std::chrono::steady_clock::now() < subscription_deadline) { + auto publisher = subscriber_room.remoteParticipant(publisher_identity).lock(); + if (publisher != nullptr) { + for (const auto& [sid, candidate] : publisher->trackPublications()) { + (void)sid; + if (candidate != nullptr && candidate->name() == track_name && candidate->subscribed() && + candidate->track() != nullptr) { + publication = candidate; + break; + } + } + } + if (publication != nullptr) { + break; + } + std::this_thread::sleep_for(10ms); + } + + ASSERT_NE(publication, nullptr) << "Timed out waiting for remote video subscription"; + ASSERT_EQ(publication->kind(), TrackKind::KIND_VIDEO); + ASSERT_TRUE(publication->simulcasted()); + + EXPECT_TRUE(publication->setVideoDimensions(kRequestedWidth, kRequestedHeight)); + EXPECT_EQ(publication->width(), kRequestedWidth); + EXPECT_EQ(publication->height(), kRequestedHeight); + EXPECT_FALSE(publication->setVideoDimensions(kRequestedWidth, kRequestedHeight)); + + EXPECT_FALSE(publication->setVideoQuality(static_cast(-1))); + EXPECT_TRUE(publication->setVideoQuality(VideoQuality::LOW)); + EXPECT_EQ(publication->videoQuality(), VideoQuality::LOW); + EXPECT_FALSE(publication->setVideoQuality(VideoQuality::LOW)); + EXPECT_TRUE(publication->setVideoDimensions(kRequestedWidth, kRequestedHeight)); + EXPECT_EQ(publication->videoQuality(), VideoQuality::HIGH); + + EXPECT_TRUE(publication->setEnabled(false)); + EXPECT_FALSE(publication->enabled()); + EXPECT_FALSE(publication->setEnabled(false)); + EXPECT_TRUE(publication->setEnabled(true)); + EXPECT_TRUE(publication->enabled()); + + EXPECT_FALSE(publication->setVideoDimensions(0, kRequestedHeight)); + + publication->setSubscribed(false); + EXPECT_FALSE(publication->setVideoDimensions(kSourceWidth, kSourceHeight)); + EXPECT_FALSE(publication->setVideoQuality(VideoQuality::MEDIUM)); + EXPECT_FALSE(publication->setEnabled(false)); + + if (track->publication() != nullptr) { + lockLocalParticipant(publisher_room)->unpublishTrack(track->publication()->sid()); + } +} + +} // namespace livekit::test diff --git a/src/tests/unit/test_track_publication.cpp b/src/tests/unit/test_track_publication.cpp index 467f6ad2..6b2a4fed 100644 --- a/src/tests/unit/test_track_publication.cpp +++ b/src/tests/unit/test_track_publication.cpp @@ -40,7 +40,13 @@ class FakeTrack : public Track { // Default-construct the proto: the publication ctor only reads via getters, // which return well-defined defaults for unset fields. Using a zero handle // keeps the `FfiHandle` a no-op on destruction. -proto::OwnedTrackPublication makeOwnedPub() { return proto::OwnedTrackPublication{}; } +proto::OwnedTrackPublication makeOwnedPub(proto::TrackKind kind = proto::TrackKind::KIND_UNKNOWN, + bool simulcasted = false) { + proto::OwnedTrackPublication owned; + owned.mutable_info()->set_kind(kind); + owned.mutable_info()->set_simulcasted(simulcasted); + return owned; +} template class TrackPublicationTest : public ::testing::Test {}; @@ -91,4 +97,53 @@ TYPED_TEST(TrackPublicationTest, ReplacingTrackReleasesPrevious) { EXPECT_EQ(first.use_count(), 1); } +TEST(RemoteTrackPublicationDimensionsTest, RejectsAudioPublication) { + RemoteTrackPublication publication(makeOwnedPub(proto::TrackKind::KIND_AUDIO)); + + EXPECT_FALSE(publication.setVideoDimensions(320, 180)); +} + +TEST(RemoteTrackPublicationDimensionsTest, RejectsZeroDimensions) { + RemoteTrackPublication publication(makeOwnedPub(proto::TrackKind::KIND_VIDEO)); + + EXPECT_FALSE(publication.setVideoDimensions(0, 180)); + EXPECT_FALSE(publication.setVideoDimensions(320, 0)); +} + +TEST(RemoteTrackPublicationDimensionsTest, RejectsUnsubscribedVideoPublication) { + RemoteTrackPublication publication(makeOwnedPub(proto::TrackKind::KIND_VIDEO)); + publication.setTrack(std::make_shared()); + + EXPECT_FALSE(publication.setVideoDimensions(320, 180)); +} + +TEST(RemoteTrackPublicationControlsTest, DefaultsToEnabledHighQuality) { + const RemoteTrackPublication publication(makeOwnedPub(proto::TrackKind::KIND_VIDEO, true)); + + EXPECT_TRUE(publication.enabled()); + EXPECT_EQ(publication.videoQuality(), VideoQuality::HIGH); +} + +TEST(RemoteTrackPublicationControlsTest, RejectsEnableUpdateWhileUnsubscribed) { + RemoteTrackPublication publication(makeOwnedPub(proto::TrackKind::KIND_VIDEO)); + publication.setTrack(std::make_shared()); + + EXPECT_FALSE(publication.setEnabled(false)); + EXPECT_TRUE(publication.enabled()); +} + +TEST(RemoteTrackPublicationControlsTest, RejectsQualityUpdateForAudioPublication) { + RemoteTrackPublication publication(makeOwnedPub(proto::TrackKind::KIND_AUDIO, true)); + + EXPECT_FALSE(publication.setVideoQuality(VideoQuality::LOW)); + EXPECT_EQ(publication.videoQuality(), VideoQuality::HIGH); +} + +TEST(RemoteTrackPublicationControlsTest, RejectsQualityUpdateForNonSimulcastPublication) { + RemoteTrackPublication publication(makeOwnedPub(proto::TrackKind::KIND_VIDEO)); + + EXPECT_FALSE(publication.setVideoQuality(VideoQuality::LOW)); + EXPECT_EQ(publication.videoQuality(), VideoQuality::HIGH); +} + } // namespace livekit::test