From 87283a2c2f62ba71178278b3b1fde654bebc8b95 Mon Sep 17 00:00:00 2001
From: Micah Spaier <85509021+JerryBerry12@users.noreply.github.com>
Date: Fri, 20 Mar 2026 20:37:55 -0400
Subject: [PATCH 01/11] First implementation of watch party sync for youtube
videos
---
gui_client/BrowserVidPlayer.cpp | 218 ++++++++++++++++++++++++++++++--
gui_client/BrowserVidPlayer.h | 4 +
gui_client/ClientThread.cpp | 24 ++++
gui_client/GUIClient.cpp | 27 ++++
gui_client/GUIClient.h | 2 +
gui_client/ObjectEditor.cpp | 19 +++
gui_client/ObjectEditor.h | 1 +
gui_client/ObjectEditor.ui | 10 ++
server/ServerWorldState.h | 12 ++
server/WorkerThread.cpp | 69 ++++++++++
shared/Protocol.h | 6 +-
shared/SubstrataLuaVM.cpp | 11 ++
shared/WorldObject.cpp | 11 ++
shared/WorldObject.h | 8 ++
14 files changed, 411 insertions(+), 11 deletions(-)
diff --git a/gui_client/BrowserVidPlayer.cpp b/gui_client/BrowserVidPlayer.cpp
index 7f9be2b4d..88b17ea82 100644
--- a/gui_client/BrowserVidPlayer.cpp
+++ b/gui_client/BrowserVidPlayer.cpp
@@ -46,7 +46,10 @@ BrowserVidPlayer::BrowserVidPlayer()
state(State_Unloaded),
m_gui_client(NULL),
html_view_handle(-1),
- using_iframe(false)
+ using_iframe(false),
+ watch_party_state_requested(false),
+ joined_watch_party(false),
+ last_watch_party_sync_check_time(0)
{}
@@ -60,6 +63,8 @@ BrowserVidPlayer::~BrowserVidPlayer()
{
destroyHTMLViewJS(html_view_handle);
html_view_handle = -1;
+ watch_party_state_requested = false;
+ joined_watch_party = false;
}
#endif
}
@@ -109,6 +114,22 @@ static std::string makeEmbedHTMLForVideoURL(const std::string& video_url, int wi
const bool autoplay = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_AUTOPLAY);
const bool loop = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_LOOP);
const bool muted = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_MUTED);
+ const bool sync_to_clock = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_SYNC_TO_CLOCK) && loop;
+
+ const std::string sync_js = sync_to_clock ?
+ "\t\t\tsetInterval(function() {\n"
+ "\t\t\t\tif(!player || !player.getDuration) return;\n"
+ "\t\t\t\tvar duration = player.getDuration();\n"
+ "\t\t\t\tif(!(duration > 1.0)) return;\n"
+ "\t\t\t\tvar target = (Date.now() * 0.001) % duration;\n"
+ "\t\t\t\tvar cur = player.getCurrentTime();\n"
+ "\t\t\t\tvar delta = target - cur;\n"
+ "\t\t\t\tif(delta > duration * 0.5) delta -= duration;\n"
+ "\t\t\t\tif(delta < -duration * 0.5) delta += duration;\n"
+ "\t\t\t\tif(Math.abs(delta) > 1.0) player.seekTo(target, true);\n"
+ "\t\t\t\tif(player.getPlayerState && player.getPlayerState() != YT.PlayerState.PLAYING) player.playVideo();\n"
+ "\t\t\t}, 2000);\n"
+ : "";
// See https://developers.google.com/youtube/player_parameters
const std::string player_params =
@@ -122,10 +143,11 @@ static std::string makeEmbedHTMLForVideoURL(const std::string& video_url, int wi
" 'loop': " + toString(loop ? 1 : 0) + ", \n"
" 'playlist': '" + video_id + "' \n"// Set the playlist to the video ID. This is needed for looping to work.
" }, \n"
- " events: { \n"
- " onReady: function(e) { \n"
- " " + (muted ? "e.target.mute();" : "") + "\n" // If video should be muted, insert a call to mute the video here.
- " } \n"
+ " events: { \n"
+ " onReady: function(e) { \n"
+ " " + (muted ? "e.target.mute();" : "") + "\n" // If video should be muted, insert a call to mute the video here.
+ " " + sync_js +
+ " } \n"
" } \n"
"}";
@@ -315,7 +337,7 @@ EM_JS(int, isYouTubeIFrameAPILoaded, (), {
});
-EM_JS(int, makeYouTubeHTMLView, (const char* video_id, int autoplay, int loop, int muted), {
+EM_JS(int, makeYouTubeHTMLView, (const char* video_id, int autoplay, int loop, int muted, int sync_to_clock), {
console.log("=================makeYouTubeHTMLView()================");
// console.log("video_id: " + UTF8ToString(video_id));
// console.log("autoplay: " + autoplay);
@@ -375,20 +397,129 @@ EM_JS(int, makeYouTubeHTMLView, (const char* video_id, int autoplay, int loop, i
//console.log("****************player onReady()****************");
if(muted) { e.target.mute(); } // If video should be muted, insert a call to mute the video here.
e.target.playVideo();
- }
- }
- };
+ if(sync_to_clock)
+ {
+ setInterval(function() {
+ if(!player || !player.getDuration)
+ return;
+
+ let duration = player.getDuration();
+ if(!(duration > 1.0))
+ return;
+
+ let target = (Date.now() * 0.001) % duration;
+ let cur = player.getCurrentTime();
+ let delta = target - cur;
+ if(delta > duration * 0.5) delta -= duration;
+ if(delta < -duration * 0.5) delta += duration;
+
+ if(Math.abs(delta) > 1.0)
+ player.seekTo(target, true);
+
+ if(player.getPlayerState && player.getPlayerState() != YT.PlayerState.PLAYING)
+ player.playVideo();
+ }, 2000);
+ }
+ }
+ }
+ };
//console.log("player_params: ");
//console.log(player_params);
var player = new YT.Player(youtube_iframe, player_params);
+ new_div.glare_yt_player = player;
//console.log("makeYouTubeHTMLView() done, returning handle " + handle.toString());
return handle;
});
+EM_JS(void, setYouTubeWatchPartyButton, (int handle, const char* text, int visible), {
+ let div = html_view_elem_handle_to_div_map[handle];
+ if(!div)
+ return;
+
+ if(!div.glare_watch_party_button)
+ {
+ let btn = document.createElement('button');
+ btn.style.position = 'absolute';
+ btn.style.left = '12px';
+ btn.style.bottom = '12px';
+ btn.style.zIndex = '10';
+ btn.style.padding = '8px 12px';
+ btn.style.background = 'rgba(20,20,20,0.85)';
+ btn.style.color = '#fff';
+ btn.style.border = '1px solid rgba(255,255,255,0.25)';
+ btn.style.borderRadius = '6px';
+ btn.style.cursor = 'pointer';
+ btn.onclick = function() {
+ div.glare_watch_party_action = (btn.innerText.indexOf('Join') >= 0) ? 2 : 1;
+ };
+ div.glare_watch_party_button = btn;
+ div.appendChild(btn);
+ }
+
+ div.glare_watch_party_button.innerText = UTF8ToString(text);
+ div.glare_watch_party_button.style.display = (visible ? 'block' : 'none');
+});
+
+
+EM_JS(int, consumeYouTubeWatchPartyAction, (int handle), {
+ let div = html_view_elem_handle_to_div_map[handle];
+ if(!div)
+ return 0;
+
+ let action = div.glare_watch_party_action ? div.glare_watch_party_action : 0;
+ div.glare_watch_party_action = 0;
+ return action;
+});
+
+
+EM_JS(double, getYouTubeCurrentTime, (int handle), {
+ let div = html_view_elem_handle_to_div_map[handle];
+ if(!div || !div.glare_yt_player || !div.glare_yt_player.getCurrentTime)
+ return 0.0;
+
+ return div.glare_yt_player.getCurrentTime();
+});
+
+
+EM_JS(void, applyYouTubeWatchPartyTargetTime, (int handle, double target_time, int should_play, int loop), {
+ let div = html_view_elem_handle_to_div_map[handle];
+ if(!div || !div.glare_yt_player)
+ return;
+
+ let player = div.glare_yt_player;
+ if(!player.getDuration || !player.getCurrentTime)
+ return;
+
+ let duration = player.getDuration();
+ if(!(duration > 0.1))
+ return;
+
+ let clamped_target = target_time;
+ if(loop)
+ {
+ clamped_target = target_time % duration;
+ if(clamped_target < 0)
+ clamped_target += duration;
+ }
+ else
+ {
+ clamped_target = Math.max(0.0, Math.min(target_time, duration));
+ }
+ let cur = player.getCurrentTime();
+ if(Math.abs(clamped_target - cur) > 0.5)
+ player.seekTo(clamped_target, true);
+
+ if(should_play && (loop || clamped_target < duration - 0.05))
+ player.playVideo();
+ else
+ player.pauseVideo();
+});
+
+
EM_JS(int, makeTwitchHTMLView, (const char* iframe_src_url), {
console.log("=================makeTwitchHTMLView()================");
console.log("video_id: " + UTF8ToString(iframe_src_url));
@@ -603,9 +734,17 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
const bool autoplay = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_AUTOPLAY);
const bool loop = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_LOOP);
const bool muted = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_MUTED);
+ const bool sync_to_clock = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_SYNC_TO_CLOCK);
conPrint("BrowserVidPlayer: Making new YouTube HTML View");
- html_view_handle = makeYouTubeHTMLView(video_id.c_str(), autoplay ? 1 : 0, loop ? 1 : 0, muted ? 1 : 0);
+ html_view_handle = makeYouTubeHTMLView(video_id.c_str(), autoplay ? 1 : 0, loop ? 1 : 0, muted ? 1 : 0, sync_to_clock ? 1 : 0);
+
+ this->watch_party_state_requested = false;
+ this->joined_watch_party = false;
+ this->last_watch_party_sync_check_time = 0;
+
+ if(sync_to_clock && html_view_handle >= 0)
+ setYouTubeWatchPartyButton(html_view_handle, "Start watch party", 1);
}
else
{
@@ -663,6 +802,8 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
{
destroyHTMLViewJS(html_view_handle);
html_view_handle = -1;
+ watch_party_state_requested = false;
+ joined_watch_party = false;
}
}
}
@@ -695,6 +836,63 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
}
matrix_string += ")";
setHTMLElementCSSTransform(html_view_handle, matrix_string.c_str());
+
+ const URLString& video_url = ob->materials[0]->emission_texture_url;
+ const bool is_http_URL = hasPrefix(video_url, "http://") || hasPrefix(video_url, "https://");
+ if(is_http_URL)
+ {
+ const URL parsed_URL = URL::parseURL(toStdString(video_url));
+ const bool is_youtube = (parsed_URL.host == "www.youtube.com" || parsed_URL.host == "youtu.be");
+ const bool sync_enabled = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_SYNC_TO_CLOCK);
+
+ if(is_youtube && sync_enabled)
+ {
+ if(!watch_party_state_requested)
+ {
+ gui_client->requestVideoWatchPartyState(ob->uid);
+ watch_party_state_requested = true;
+ }
+
+ if(joined_watch_party)
+ setYouTubeWatchPartyButton(html_view_handle, "Join watch party", 0);
+ else if(ob->video_watch_party_active)
+ setYouTubeWatchPartyButton(html_view_handle, "Join watch party", 1);
+ else
+ setYouTubeWatchPartyButton(html_view_handle, "Start watch party", 1);
+
+ const int action = consumeYouTubeWatchPartyAction(html_view_handle);
+ if(action == 1) // Start watch party
+ {
+ const double start_video_time = getYouTubeCurrentTime(html_view_handle);
+ gui_client->startVideoWatchParty(ob->uid, start_video_time);
+ joined_watch_party = true;
+ gui_client->requestVideoWatchPartyState(ob->uid);
+ }
+ else if(action == 2) // Join watch party
+ {
+ joined_watch_party = true;
+ gui_client->requestVideoWatchPartyState(ob->uid);
+ }
+
+ if(joined_watch_party && ob->video_watch_party_active && gui_client->world_state.nonNull())
+ {
+ if(anim_time - last_watch_party_sync_check_time > 0.5)
+ {
+ last_watch_party_sync_check_time = anim_time;
+
+ const double cur_global_time = gui_client->world_state->getCurrentGlobalTime();
+ const double elapsed = myMax(0.0, cur_global_time - ob->video_watch_party_start_global_time);
+ const double target_time = ob->video_watch_party_start_video_time + elapsed;
+ const bool loop = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_LOOP);
+
+ if(loop)
+ applyYouTubeWatchPartyTargetTime(html_view_handle, target_time, 1, 1);
+ else
+ applyYouTubeWatchPartyTargetTime(html_view_handle, target_time, 1, 0);
+ }
+ }
+ }
+ }
}
else
{
diff --git a/gui_client/BrowserVidPlayer.h b/gui_client/BrowserVidPlayer.h
index 5a97bb883..917a180dc 100644
--- a/gui_client/BrowserVidPlayer.h
+++ b/gui_client/BrowserVidPlayer.h
@@ -63,6 +63,10 @@ class BrowserVidPlayer : public RefCounted
bool using_iframe;
bool previous_is_visible;
+
+ bool watch_party_state_requested;
+ bool joined_watch_party;
+ double last_watch_party_sync_check_time;
};
diff --git a/gui_client/ClientThread.cpp b/gui_client/ClientThread.cpp
index 93e5bbefc..bb6015336 100644
--- a/gui_client/ClientThread.cpp
+++ b/gui_client/ClientThread.cpp
@@ -906,6 +906,30 @@ void ClientThread::readAndHandleMessage(const uint32 peer_protocol_version)
}
break;
}
+ case Protocol::VideoWatchPartyState:
+ {
+ const UID object_uid = readUIDFromStream(msg_buffer);
+ const bool active = msg_buffer.readUInt32() != 0;
+ const uint32 owner_user_id = msg_buffer.readUInt32();
+ const double start_global_time = msg_buffer.readDouble();
+ const double start_video_time = msg_buffer.readDouble();
+
+ {
+ Lock lock(world_state->mutex);
+ auto res = world_state->objects.find(object_uid);
+ if(res != world_state->objects.end())
+ {
+ WorldObject* ob = res.getValue().ptr();
+ ob->video_watch_party_active = active;
+ ob->video_watch_party_owner_user_id = owner_user_id;
+ ob->video_watch_party_start_global_time = start_global_time;
+ ob->video_watch_party_start_video_time = start_video_time;
+ ob->from_remote_video_watch_party_dirty = true;
+ world_state->dirty_from_remote_objects.insert(ob);
+ }
+ }
+ break;
+ }
case Protocol::ObjectPhysicsOwnershipTaken:
{
const UID object_uid = readUIDFromStream(msg_buffer);
diff --git a/gui_client/GUIClient.cpp b/gui_client/GUIClient.cpp
index 176b3635c..703fed1f6 100644
--- a/gui_client/GUIClient.cpp
+++ b/gui_client/GUIClient.cpp
@@ -6927,6 +6927,10 @@ void GUIClient::timerEvent(const MouseCursorState& mouse_cursor_state)
ob->from_remote_physics_ownership_dirty = false;
}
+ else if(ob->from_remote_video_watch_party_dirty)
+ {
+ ob->from_remote_video_watch_party_dirty = false;
+ }
if(ob->from_remote_transform_dirty)
{
@@ -12528,6 +12532,29 @@ void GUIClient::sendLightmapNeededFlagsSlot()
}
+void GUIClient::requestVideoWatchPartyState(const UID& object_uid)
+{
+ if(this->connection_state != ServerConnectionState_Connected || this->client_thread.isNull())
+ return;
+
+ MessageUtils::initPacket(scratch_packet, Protocol::VideoWatchPartyQueryState);
+ writeToStream(object_uid, scratch_packet);
+ enqueueMessageToSend(*this->client_thread, scratch_packet);
+}
+
+
+void GUIClient::startVideoWatchParty(const UID& object_uid, double start_video_time)
+{
+ if(this->connection_state != ServerConnectionState_Connected || this->client_thread.isNull())
+ return;
+
+ MessageUtils::initPacket(scratch_packet, Protocol::VideoWatchPartyStart);
+ writeToStream(object_uid, scratch_packet);
+ scratch_packet.writeDouble(start_video_time);
+ enqueueMessageToSend(*this->client_thread, scratch_packet);
+}
+
+
static std::string makeURL(const std::string& server_hostname, const std::string& server_worldname, const Vec3d& pos, double heading_deg)
{
// Use doubleToStringNDecimalPlaces instead of doubleToStringMaxNDecimalPlaces, as the latter is distracting due to flickering URL length when moving.
diff --git a/gui_client/GUIClient.h b/gui_client/GUIClient.h
index 9fafacf41..34bf54eda 100644
--- a/gui_client/GUIClient.h
+++ b/gui_client/GUIClient.h
@@ -243,6 +243,8 @@ class GUIClient : public ObLoadingCallbacks, public PrintOutput, public PhysicsW
void viewportResized(int w, int h);
void updateGroundPlane();
void sendLightmapNeededFlagsSlot();
+ void requestVideoWatchPartyState(const UID& object_uid);
+ void startVideoWatchParty(const UID& object_uid, double start_video_time);
void useActionTriggered(bool use_mouse_cursor); // if use_mouse_cursor is false, use crosshair as cursor instead.
void loginButtonClicked();
void signupButtonClicked();
diff --git a/gui_client/ObjectEditor.cpp b/gui_client/ObjectEditor.cpp
index 22cf93524..3c939e59e 100644
--- a/gui_client/ObjectEditor.cpp
+++ b/gui_client/ObjectEditor.cpp
@@ -103,8 +103,10 @@ ObjectEditor::ObjectEditor(QWidget *parent)
connect(this->videoAutoplayCheckBox, SIGNAL(toggled(bool)), this, SIGNAL(objectChanged()));
connect(this->videoLoopCheckBox, SIGNAL(toggled(bool)), this, SIGNAL(objectChanged()));
connect(this->videoMutedCheckBox, SIGNAL(toggled(bool)), this, SIGNAL(objectChanged()));
+ connect(this->videoSyncCheckBox, SIGNAL(toggled(bool)), this, SIGNAL(objectChanged()));
connect(this->videoURLFileSelectWidget, SIGNAL(filenameChanged(QString&)), this, SIGNAL(objectChanged()));
+ connect(this->videoURLFileSelectWidget, SIGNAL(filenameChanged(QString&)), this, SLOT(videoURLChanged(QString&)));
connect(this->videoVolumeDoubleSpinBox, SIGNAL(valueChanged(double)), this, SIGNAL(objectChanged()));
connect(this->audioAutoplayCheckBox, SIGNAL(toggled(bool)), this, SIGNAL(objectChanged()));
@@ -257,11 +259,16 @@ void ObjectEditor::setFromObject(const WorldObject& ob, int selected_mat_index_,
SignalBlocker::setChecked(this->videoAutoplayCheckBox, BitUtils::isBitSet(ob.flags, WorldObject::VIDEO_AUTOPLAY));
SignalBlocker::setChecked(this->videoLoopCheckBox, BitUtils::isBitSet(ob.flags, WorldObject::VIDEO_LOOP));
SignalBlocker::setChecked(this->videoMutedCheckBox, BitUtils::isBitSet(ob.flags, WorldObject::VIDEO_MUTED));
+ SignalBlocker::setChecked(this->videoSyncCheckBox, BitUtils::isBitSet(ob.flags, WorldObject::VIDEO_SYNC_TO_CLOCK));
SignalBlocker::setChecked(this->audioAutoplayCheckBox, BitUtils::isBitSet(ob.flags, WorldObject::AUDIO_AUTOPLAY));
SignalBlocker::setChecked(this->audioLoopCheckBox, BitUtils::isBitSet(ob.flags, WorldObject::AUDIO_LOOP));
this->videoURLFileSelectWidget->setFilename(QtUtils::toQString((!ob.materials.empty()) ? ob.materials[0]->emission_texture_url : ""));
+ {
+ QString url = this->videoURLFileSelectWidget->filename();
+ videoURLChanged(url);
+ }
SignalBlocker::setValue(videoVolumeDoubleSpinBox, ob.audio_volume);
@@ -533,6 +540,7 @@ void ObjectEditor::toObject(WorldObject& ob_out)
BitUtils::setOrZeroBit(ob_out.flags, WorldObject::VIDEO_AUTOPLAY, this->videoAutoplayCheckBox->isChecked());
BitUtils::setOrZeroBit(ob_out.flags, WorldObject::VIDEO_LOOP, this->videoLoopCheckBox ->isChecked());
BitUtils::setOrZeroBit(ob_out.flags, WorldObject::VIDEO_MUTED, this->videoMutedCheckBox ->isChecked());
+ BitUtils::setOrZeroBit(ob_out.flags, WorldObject::VIDEO_SYNC_TO_CLOCK, this->videoSyncCheckBox->isChecked());
BitUtils::setOrZeroBit(ob_out.flags, WorldObject::AUDIO_AUTOPLAY, this->audioAutoplayCheckBox->isChecked());
BitUtils::setOrZeroBit(ob_out.flags, WorldObject::AUDIO_LOOP, this->audioLoopCheckBox ->isChecked());
@@ -820,6 +828,17 @@ void ObjectEditor::targetURLChanged()
}
+void ObjectEditor::videoURLChanged(QString&)
+{
+ const std::string lower_url = ::toLowerCase(QtUtils::toIndString(this->videoURLFileSelectWidget->filename()));
+ const bool is_youtube_url = StringUtils::containsString(lower_url, "youtube.com") || StringUtils::containsString(lower_url, "youtu.be");
+
+ this->videoSyncCheckBox->setEnabled(is_youtube_url);
+ if(!is_youtube_url)
+ SignalBlocker::setChecked(this->videoSyncCheckBox, false);
+}
+
+
void ObjectEditor::scriptTextEditChanged()
{
edit_timer->start();
diff --git a/gui_client/ObjectEditor.h b/gui_client/ObjectEditor.h
index 147971f7f..2bf75986e 100644
--- a/gui_client/ObjectEditor.h
+++ b/gui_client/ObjectEditor.h
@@ -77,6 +77,7 @@ private slots:
void on_materialComboBox_currentIndexChanged(int index);
void on_newMaterialPushButton_clicked(bool checked);
void targetURLChanged();
+ void videoURLChanged(QString&);
void scriptTextEditChanged();
void scriptChangedFromEditor();
void on_editScriptPushButton_clicked(bool checked);
diff --git a/gui_client/ObjectEditor.ui b/gui_client/ObjectEditor.ui
index ffc5e7fe6..db9551e98 100644
--- a/gui_client/ObjectEditor.ui
+++ b/gui_client/ObjectEditor.ui
@@ -698,6 +698,16 @@
+ -
+
+
+ For looped YouTube videos, periodically sync playback time across viewers.
+
+
+ Sync (YouTube)
+
+
+
diff --git a/server/ServerWorldState.h b/server/ServerWorldState.h
index 3b3941c1a..2a2d14975 100644
--- a/server/ServerWorldState.h
+++ b/server/ServerWorldState.h
@@ -221,6 +221,15 @@ class ServerWorldState : public ThreadSafeRefCounted
public:
ServerWorldState() : db_dirty(false) {}
+ struct VideoWatchPartyState
+ {
+ VideoWatchPartyState() : active(false), owner_user_id(std::numeric_limits::max()), start_global_time(0), start_video_time(0) {}
+ bool active;
+ uint32 owner_user_id;
+ double start_global_time;
+ double start_video_time;
+ };
+
void addParcelAsDBDirty (const ParcelRef parcel, WorldStateLock& /*world_state_lock*/) { db_dirty_parcels.insert(parcel); }
void addWorldObjectAsDBDirty(const WorldObjectRef ob, WorldStateLock& /*world_state_lock*/) { db_dirty_world_objects.insert(ob); }
void addLODChunkAsDBDirty (const LODChunkRef ob, WorldStateLock& /*world_state_lock*/) { db_dirty_lod_chunks.insert(ob); }
@@ -248,6 +257,7 @@ class ServerWorldState : public ThreadSafeRefCounted
ParcelMapType& getParcels(WorldStateLock& /*world_state_lock*/) { return parcels; }
LODChunkMapType& getLODChunks(WorldStateLock& /*world_state_lock*/) { return lod_chunks; }
ChatBotMapType& getChatBots(WorldStateLock& /*world_state_lock*/) { return chatbots; }
+ std::map& getVideoWatchParties(WorldStateLock& /*world_state_lock*/) { return video_watch_parties; }
ParcelMapType parcels; // TODO: make private. Lots of compile errors to fix when doing so.
@@ -258,6 +268,8 @@ class ServerWorldState : public ThreadSafeRefCounted
std::unordered_set& getDBDirtyChatBots(WorldStateLock& /*world_state_lock*/) { return db_dirty_chatbots; }
AvatarRef createAndInsertAvatarForChatBot(ServerAllWorldsState* all_world_state, const ChatBot* chatbot, WorldStateLock& /*world_state_lock*/);
+
+ std::map video_watch_parties;
private:
ObjectMapType objects;
DirtyFromRemoteObjectSetType dirty_from_remote_objects; // TODO: could just use vector for this, and avoid duplicates by checking object dirty flag.
diff --git a/server/WorkerThread.cpp b/server/WorkerThread.cpp
index 1278cced8..e4db07727 100644
--- a/server/WorkerThread.cpp
+++ b/server/WorkerThread.cpp
@@ -2198,6 +2198,74 @@ void WorkerThread::doRun()
}
}
}
+ break;
+ }
+ case Protocol::VideoWatchPartyStart:
+ {
+ const UID object_uid = readUIDFromStream(msg_buffer);
+ const double requested_start_video_time = msg_buffer.readDouble();
+
+ ServerWorldState::VideoWatchPartyState state_to_send;
+ bool should_send = false;
+
+ {
+ WorldStateLock lock(world_state->mutex);
+ auto ob_res = cur_world_state->getObjects(lock).find(object_uid);
+ if(ob_res != cur_world_state->getObjects(lock).end())
+ {
+ WorldObject* ob = ob_res->second.getPointer();
+ if(ob->object_type == WorldObject::ObjectType_Video && BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_SYNC_TO_CLOCK))
+ {
+ auto& state = cur_world_state->getVideoWatchParties(lock)[object_uid];
+ if(!state.active)
+ {
+ state.active = true;
+ state.owner_user_id = client_user_id.valid() ? client_user_id.value() : std::numeric_limits::max();
+ state.start_global_time = server->getCurrentGlobalTime();
+ state.start_video_time = myMax(0.0, requested_start_video_time);
+ }
+
+ state_to_send = state;
+ should_send = true;
+ }
+ }
+ }
+
+ if(should_send)
+ {
+ MessageUtils::initPacket(scratch_packet, Protocol::VideoWatchPartyState);
+ writeToStream(object_uid, scratch_packet);
+ scratch_packet.writeUInt32(state_to_send.active ? 1u : 0u);
+ scratch_packet.writeUInt32(state_to_send.owner_user_id);
+ scratch_packet.writeDouble(state_to_send.start_global_time);
+ scratch_packet.writeDouble(state_to_send.start_video_time);
+ MessageUtils::updatePacketLengthField(scratch_packet);
+ enqueuePacketToBroadcast(scratch_packet);
+ }
+
+ break;
+ }
+ case Protocol::VideoWatchPartyQueryState:
+ {
+ const UID object_uid = readUIDFromStream(msg_buffer);
+
+ ServerWorldState::VideoWatchPartyState state_to_send;
+ {
+ WorldStateLock lock(world_state->mutex);
+ auto res = cur_world_state->getVideoWatchParties(lock).find(object_uid);
+ if(res != cur_world_state->getVideoWatchParties(lock).end())
+ state_to_send = res->second;
+ }
+
+ MessageUtils::initPacket(scratch_packet, Protocol::VideoWatchPartyState);
+ writeToStream(object_uid, scratch_packet);
+ scratch_packet.writeUInt32(state_to_send.active ? 1u : 0u);
+ scratch_packet.writeUInt32(state_to_send.owner_user_id);
+ scratch_packet.writeDouble(state_to_send.start_global_time);
+ scratch_packet.writeDouble(state_to_send.start_video_time);
+ MessageUtils::updatePacketLengthField(scratch_packet);
+ enqueueDataToSend(scratch_packet);
+
break;
}
case Protocol::ObjectPhysicsOwnershipTaken:
@@ -2342,6 +2410,7 @@ void WorkerThread::doRun()
// Mark object as dead
ob->state = WorldObject::State_Dead;
ob->from_remote_other_dirty = true;
+ cur_world_state->getVideoWatchParties(lock).erase(object_uid);
cur_world_state->addWorldObjectAsDBDirty(ob, lock);
cur_world_state->getDirtyFromRemoteObjects(lock).insert(ob);
diff --git a/shared/Protocol.h b/shared/Protocol.h
index c60b24ce0..96fffced0 100644
--- a/shared/Protocol.h
+++ b/shared/Protocol.h
@@ -43,13 +43,14 @@ CyberspaceProtocolVersion
47: Added UserGestureSettingsChanged message.
48: Added ping+pong messages.
49: Added AvatarSatOnSeat, AvatarGotUpFromSeat messages.
+50: Added video watch party start/query/state messages.
*/
namespace Protocol
{
const uint32 CyberspaceHello = 1357924680;
-const uint32 CyberspaceProtocolVersion = 49;
+const uint32 CyberspaceProtocolVersion = 50;
const uint32 ClientProtocolOK = 10000;
const uint32 ClientProtocolTooOld = 10001;
@@ -107,6 +108,9 @@ const uint32 ObjectPhysicsOwnershipTaken = 3013;
const uint32 ObjectPhysicsTransformUpdate = 3016;
const uint32 ObjectContentChanged = 3017;
const uint32 SummonObject = 3030;
+const uint32 VideoWatchPartyStart = 3040; // Client requests to start an authoritative watch party for a video object.
+const uint32 VideoWatchPartyQueryState = 3041; // Client queries current watch party state for a video object.
+const uint32 VideoWatchPartyState = 3042; // Server sends watch party state for a video object.
const uint32 CreateObject = 3004; // Client wants to create an object.
const uint32 DestroyObject = 3005; // Client wants to destroy an object.
diff --git a/shared/SubstrataLuaVM.cpp b/shared/SubstrataLuaVM.cpp
index 4c4490e56..cf3163631 100644
--- a/shared/SubstrataLuaVM.cpp
+++ b/shared/SubstrataLuaVM.cpp
@@ -58,6 +58,7 @@ enum StringAtomEnum
Atom_video_autoplay,
Atom_video_loop,
Atom_video_muted,
+ Atom_video_sync,
Atom_mass,
Atom_friction,
Atom_restitution,
@@ -126,6 +127,7 @@ static StringAtom string_atoms[] =
StringAtom({"video_autoplay", Atom_video_autoplay, }),
StringAtom({"video_loop", Atom_video_loop, }),
StringAtom({"video_muted", Atom_video_muted, }),
+ StringAtom({"video_sync", Atom_video_sync, }),
StringAtom({"mass", Atom_mass, }),
StringAtom({"friction", Atom_friction, }),
StringAtom({"restitution", Atom_restitution, }),
@@ -1281,6 +1283,10 @@ static int worldObjectClassIndexMetaMethod(lua_State* state)
assert(stringEqual(key_str, "video_muted"));
lua_pushboolean(state, BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_MUTED));
break;
+ case Atom_video_sync:
+ assert(stringEqual(key_str, "video_sync"));
+ lua_pushboolean(state, BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_SYNC_TO_CLOCK));
+ break;
case Atom_mass:
assert(stringEqual(key_str, "mass"));
lua_pushnumber(state, ob->mass);
@@ -1476,6 +1482,11 @@ static int worldObjectClassNewIndexMetaMethod(lua_State* state)
BitUtils::setOrZeroBit(ob->flags, WorldObject::VIDEO_MUTED, LuaUtils::getBool(state, /*index=*/3));
other_changed = true;
break;
+ case Atom_video_sync:
+ assert(stringEqual(key_str, "video_sync"));
+ BitUtils::setOrZeroBit(ob->flags, WorldObject::VIDEO_SYNC_TO_CLOCK, LuaUtils::getBool(state, /*index=*/3));
+ other_changed = true;
+ break;
case Atom_mass:
assert(stringEqual(key_str, "mass"));
ob->mass = LuaUtils::getFloat(state, /*index=*/3);
diff --git a/shared/WorldObject.cpp b/shared/WorldObject.cpp
index edd4c9d97..7af2b3140 100644
--- a/shared/WorldObject.cpp
+++ b/shared/WorldObject.cpp
@@ -74,6 +74,7 @@ WorldObject::WorldObject() noexcept
from_remote_content_dirty = false;
from_remote_flags_dirty = false;
from_remote_physics_ownership_dirty = false;
+ from_remote_video_watch_party_dirty = false;
from_local_transform_dirty = false;
from_local_other_dirty = false;
from_local_physics_dirty = false;
@@ -126,6 +127,11 @@ WorldObject::WorldObject() noexcept
physics_owner_id = std::numeric_limits::max();
last_physics_ownership_change_global_time = 0;
+ video_watch_party_active = false;
+ video_watch_party_owner_user_id = std::numeric_limits::max();
+ video_watch_party_start_global_time = 0;
+ video_watch_party_start_video_time = 0;
+
transmission_time_offset = 0;
chunk_batch0_start = chunk_batch0_end = chunk_batch1_start = chunk_batch1_end = 0;
@@ -1033,6 +1039,11 @@ void WorldObject::copyNetworkStateFrom(const WorldObject& other)
physics_owner_id = other.physics_owner_id;
last_physics_ownership_change_global_time = other.last_physics_ownership_change_global_time;
+ video_watch_party_active = other.video_watch_party_active;
+ video_watch_party_owner_user_id = other.video_watch_party_owner_user_id;
+ video_watch_party_start_global_time = other.video_watch_party_start_global_time;
+ video_watch_party_start_video_time = other.video_watch_party_start_video_time;
+
exclude_from_lod_chunk_mesh = BitUtils::isBitSet(flags, WorldObject::EXCLUDE_FROM_LOD_CHUNK_MESH);
diff --git a/shared/WorldObject.h b/shared/WorldObject.h
index 2bca97759..cccc94909 100644
--- a/shared/WorldObject.h
+++ b/shared/WorldObject.h
@@ -333,6 +333,7 @@ class WorldObject // : public ThreadSafeRefCounted
static const uint32 EXCLUDE_FROM_LOD_CHUNK_MESH = 512; // Should this object be excluded from LOD Chunk meshes? (for e.g. moving objects)
static const uint32 AUDIO_AUTOPLAY = 1024; // For objects that play audio, should the audio auto-play?
static const uint32 AUDIO_LOOP = 2048; // For objects that play audio, should the audio loop?
+ static const uint32 VIDEO_SYNC_TO_CLOCK = 4096; // For YouTube video objects, should playback be periodically synced to shared clock time?
uint32 flags;
TimeStamp created_time;
@@ -407,6 +408,7 @@ class WorldObject // : public ThreadSafeRefCounted
bool from_remote_content_dirty; // Content has changed remotely.
bool from_remote_flags_dirty; // Flags have been changed remotely
bool from_remote_physics_ownership_dirty; // Physics ownership has been changed remotely.
+ bool from_remote_video_watch_party_dirty; // Video watch party state changed remotely.
bool from_local_transform_dirty; // Transformation has been changed locally
bool from_local_other_dirty; // Something else has been changed locally
@@ -417,6 +419,12 @@ class WorldObject // : public ThreadSafeRefCounted
uint32 last_transform_update_avatar_uid; // Avatar UID of last client that sent the last ObjectTransformUpdate or ObjectPhysicsTransformUpdate for this message.
double last_transform_client_time;
+ // Transient (not serialized in object stream). For authoritative video watch-party sync.
+ bool video_watch_party_active;
+ uint32 video_watch_party_owner_user_id;
+ double video_watch_party_start_global_time;
+ double video_watch_party_start_video_time;
+
static const uint32 AUDIO_SOURCE_URL_CHANGED = 1; // Set when audio_source_url is changed
static const uint32 SCRIPT_CHANGED = 2; // Set when script is changed
static const uint32 CONTENT_CHANGED = 4; // Set when script is changed
From e32a3373eae6dacfa082fe7a4dd00dfe7f99b9eb Mon Sep 17 00:00:00 2001
From: Micah Spaier <85509021+JerryBerry12@users.noreply.github.com>
Date: Fri, 20 Mar 2026 21:17:43 -0400
Subject: [PATCH 02/11] Copy this from my master so i can build from the
workflow, will remove before opening pr
---
scripts/copy_files_to_output.rb | 148 ++++++++++++++++++++++++--------
1 file changed, 114 insertions(+), 34 deletions(-)
diff --git a/scripts/copy_files_to_output.rb b/scripts/copy_files_to_output.rb
index 0cfd56196..111df31f7 100644
--- a/scripts/copy_files_to_output.rb
+++ b/scripts/copy_files_to_output.rb
@@ -9,13 +9,17 @@
$copy_cef = true
-$copy_bugsplat = true
+$copy_bugsplat = false
+$only_config = nil
def printUsage()
puts "Usage: copy_files_to_output.rb [arguments]"
puts ""
puts "\t--no_cef, \t\tSkip copying CEF files."
+ puts "\t--config=\tOnly process the specified build configuration (Windows only)."
+ puts ""
+ puts "\t"
puts ""
puts "\t--help, -h\t\tShows this help."
puts ""
@@ -28,6 +32,21 @@ def printUsage()
$copy_cef = false
elsif opt[0] == "--no_bugsplat"
$copy_bugsplat = false
+ elsif opt[0].start_with?("--config")
+ # Accept --config=VALUE or --config VALUE (ArgumentParser may provide value in opt[1])
+ val = nil
+ if opt[0].include?("=")
+ val = opt[0].split("=",2)[1]
+ elsif opt.length > 1 && opt[1]
+ val = opt[1]
+ end
+ valid = ["Debug", "RelWithDebInfo", "Release"]
+ if val && valid.include?(val)
+ $only_config = val
+ else
+ puts "Invalid or missing value for --config. Expected one of: #{valid.join(', ')}"
+ exit 1
+ end
elsif opt[0] == "--help" || opt[0] == "-h"
printUsage()
exit 0
@@ -40,37 +59,24 @@ def printUsage()
def copy_files(vs_version, substrata_repos_dir, glare_core_repos_dir)
- begin
- output_dir = getCmakeBuildDir(vs_version, "Debug")
-
- copyCyberspaceResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
- copyCyberspaceServerResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
- copyCEFRedistWindows(output_dir, true) if $copy_cef
- copyBugSplatRedist(output_dir) if $copy_bugsplat
- copyQtRedistWindows(vs_version, output_dir, true)
- copySDLRedistWindows(vs_version, output_dir, true)
- end
+ # Process configured build configurations. If $only_config is set (via --config),
+ # only that configuration will be processed. This is intended for Windows use.
+ configs = ["Debug", "RelWithDebInfo", "Release"]
+ configs.each do |cfg|
+ next if $only_config && cfg != $only_config
- begin
- output_dir = getCmakeBuildDir(vs_version, "RelWithDebInfo")
+ output_dir = getCmakeBuildDir(vs_version, cfg)
copyCyberspaceResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
- copyCyberspaceServerResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
- copyCEFRedistWindows(output_dir) if $copy_cef
+ # Preserve previous behavior where Debug passed an extra 'true' flag.
+ if cfg == "Debug"
+ copyCEFRedistWindows(output_dir, true) if $copy_cef
+ else
+ copyCEFRedistWindows(output_dir) if $copy_cef
+ end
copyBugSplatRedist(output_dir) if $copy_bugsplat
- copyQtRedistWindows(vs_version, output_dir, false)
- copySDLRedistWindows(vs_version, output_dir, false)
- end
-
- begin
- output_dir = getCmakeBuildDir(vs_version, "Release")
-
- copyCyberspaceResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
- copyCyberspaceServerResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
- copyCEFRedistWindows(output_dir) if $copy_cef
- copyBugSplatRedist(output_dir) if $copy_bugsplat
- copyQtRedistWindows(vs_version, output_dir, false)
- copySDLRedistWindows(vs_version, output_dir, false)
+ copyQtRedistWindows(vs_version, output_dir, cfg == "Debug")
+ copySDLRedistWindows(vs_version, output_dir, cfg == "Debug")
end
end
@@ -82,13 +88,87 @@ def copy_files(vs_version, substrata_repos_dir, glare_core_repos_dir)
if OS.windows?
copy_files(2022, substrata_repos_dir, glare_core_repos_dir)
elsif OS.mac?
+ #
+ # For mac we need to populate the actual .app bundle that contains the built binary.
+ # Previously the script always used the Debug build dir which resulted in populating
+ # the skeleton app (e.g. test_builds) instead of the real Release app when BUILD_CONFIG=Release.
+ #
+ # Strategy:
+ # 1) Prefer CYBERSPACE_OUTPUT/gui_client.app if it exists and contains the binary
+ # 2) Prefer the config requested by the environment variable BUILD_CONFIG (if set)
+ # 3) Search substrata_output and substrata_build for gui_client.app that actually contains the binary
+ # 4) Copy resources/CEF into the first app bundle found
+ # 5) As a last resort fall back to the old Debug location to preserve backward compatibility
+ #
begin
- build_dir = getCmakeBuildDir(0, "Debug")
- appdir = build_dir + "/gui_client.app"
- output_dir = build_dir + "/gui_client.app/Contents/MacOS/../Resources"
-
- copyCyberspaceResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
- copyCEFRedistMac(build_dir, appdir) if $copy_cef
+ # 1) Quick check: CYBERSPACE_OUTPUT (CI sets this)
+ cs_out = ENV['CYBERSPACE_OUTPUT']
+ if cs_out && !cs_out.empty?
+ app_candidate = File.join(cs_out, "gui_client.app")
+ bin_path = File.join(app_candidate, "Contents", "MacOS", "gui_client")
+ if File.exist?(bin_path)
+ puts "copy_files_to_output.rb: detected built app in CYBERSPACE_OUTPUT: #{app_candidate}"
+ appdir = app_candidate
+ output_dir = File.join(appdir, "Contents", "Resources")
+ copyCyberspaceResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
+ copyCEFRedistMac(File.dirname(appdir), appdir) if $copy_cef
+ # done
+ exit 0
+ else
+ puts "copy_files_to_output.rb: CYBERSPACE_OUTPUT set to #{cs_out} but no binary found at #{bin_path}"
+ end
+ else
+ puts "copy_files_to_output.rb: CYBERSPACE_OUTPUT not set or empty; will search common build locations"
+ end
+
+ # 2) & 3) Search per-config and common locations
+ preferred = ENV['BUILD_CONFIG'] ? ENV['BUILD_CONFIG'] : nil
+ configs = []
+ configs << preferred if preferred
+ # common configs
+ configs += ['Release', 'RelWithDebInfo', 'Debug']
+ # uniq preserve order
+ configs = configs.compact.uniq
+
+ found_app = nil
+ configs.each do |cfg|
+ build_dir = getCmakeBuildDir(0, cfg)
+ # There are a few places the .app may be placed; check common ones
+ candidates = [
+ File.join(build_dir, "gui_client.app"),
+ File.join(build_dir, "gui_client.app/Contents/MacOS/../"), # legacy pattern
+ File.join(File.dirname(build_dir), "substrata_output", "gui_client.app"),
+ File.join(File.dirname(build_dir), "substrata_output", "test_builds", "gui_client.app"),
+ File.join(File.dirname(build_dir), "substrata_output")
+ ]
+ candidates.each do |cand|
+ # normalize and check for real binary presence
+ next if cand.nil? || cand.empty?
+ cand_dir = cand.gsub(/\/+/, '/')
+ bin_path = File.join(cand_dir, "Contents", "MacOS", "gui_client")
+ if File.exist?(bin_path)
+ found_app = cand_dir
+ break
+ end
+ end
+ break if found_app
+ end
+
+ if found_app
+ appdir = found_app
+ output_dir = File.join(appdir, "Contents", "Resources")
+ puts "copy_files_to_output.rb: populating app at #{appdir} (detected binary present)"
+ copyCyberspaceResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
+ copyCEFRedistMac(File.dirname(appdir), appdir) if $copy_cef
+ else
+ # Last-resort fallback to previous Debug behavior to avoid surprising regressions.
+ puts "copy_files_to_output.rb: no built gui_client.app with binary found for configs #{configs.inspect}; falling back to Debug build dir."
+ build_dir = getCmakeBuildDir(0, "Debug")
+ appdir = File.join(build_dir, "gui_client.app")
+ output_dir = File.join(appdir, "Contents", "MacOS", "..", "Resources")
+ copyCyberspaceResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
+ copyCEFRedistMac(build_dir, appdir) if $copy_cef
+ end
end
begin
From d96584b1d933223c632af85cb862164ae414caab Mon Sep 17 00:00:00 2001
From: Micah Spaier <85509021+JerryBerry12@users.noreply.github.com>
Date: Fri, 20 Mar 2026 21:37:27 -0400
Subject: [PATCH 03/11] Copy this from my master so i can build from the
workflow, will remove before opening pr
---
scripts/dist_utils.rb | 36 +++++++++++++++++++++++++++++++-----
1 file changed, 31 insertions(+), 5 deletions(-)
diff --git a/scripts/dist_utils.rb b/scripts/dist_utils.rb
index 9528f1c41..7f19e832d 100644
--- a/scripts/dist_utils.rb
+++ b/scripts/dist_utils.rb
@@ -99,10 +99,36 @@ def copySDLRedistWindows(vs_version, target_dir, copy_debug)
# Get SDL path.
glare_core_libs_dir = getAndCheckEnvVar('GLARE_CORE_LIBS')
- sdl_dir = "#{glare_core_libs_dir}/SDL/sdl_2.30.9_build"
-
- FileUtils.cp("#{sdl_dir}/Debug/SDL2d.dll", target_dir, :verbose => true) if copy_debug
- FileUtils.cp("#{sdl_dir}/Release/SDL2.dll", target_dir, :verbose => true) if !copy_debug
+ sdl_build_dir = "#{glare_core_libs_dir}/SDL/sdl_2.30.9_build"
+ sdl_install_dir = "#{glare_core_libs_dir}/SDL/sdl_2.30.9_install"
+
+ # Prefer the build dir (used in CI when building from source), but fall back
+ # to an install dir (used when deps are supplied prebuilt from the store).
+ if copy_debug
+ src = File.join(sdl_build_dir, "Debug", "SDL2d.dll")
+ if !File.exist?(src)
+ # fallback
+ alt = File.join(sdl_install_dir, "Debug", "SDL2d.dll")
+ src = alt if File.exist?(alt)
+ end
+ if File.exist?(src)
+ FileUtils.cp(src, target_dir, :verbose => true)
+ else
+ STDERR.puts "Warning: SDL debug DLL not found at #{src} (skipping)"
+ end
+ else
+ src = File.join(sdl_build_dir, "Release", "SDL2.dll")
+ if !File.exist?(src)
+ # fallback
+ alt = File.join(sdl_install_dir, "Release", "SDL2.dll")
+ src = alt if File.exist?(alt)
+ end
+ if File.exist?(src)
+ FileUtils.cp(src, target_dir, :verbose => true)
+ else
+ STDERR.puts "Warning: SDL release DLL not found at #{src} (skipping)"
+ end
+ end
end
@@ -253,4 +279,4 @@ def copyCyberspaceServerResources(substrata_repos_dir, glare_core_repos_dir, dis
# Copy licence.txt
FileUtils.cp_r(substrata_repos_dir + "/docs/licence.txt", "#{dist_dir}/", :verbose => true)
-end
+end
\ No newline at end of file
From 0f47dc8908f17acc1b4b82a8ef9705e5f1f8021c Mon Sep 17 00:00:00 2001
From: Micah Spaier <85509021+JerryBerry12@users.noreply.github.com>
Date: Fri, 20 Mar 2026 23:07:30 -0400
Subject: [PATCH 04/11] Added watch party stuff to full desktop client instead
of webclient
---
gui_client/BrowserVidPlayer.cpp | 92 ++++++++++++++++++++++++++-------
gui_client/EmbeddedBrowser.cpp | 56 ++++++++++++++++++++
gui_client/EmbeddedBrowser.h | 1 +
3 files changed, 131 insertions(+), 18 deletions(-)
diff --git a/gui_client/BrowserVidPlayer.cpp b/gui_client/BrowserVidPlayer.cpp
index 88b17ea82..a62dd22df 100644
--- a/gui_client/BrowserVidPlayer.cpp
+++ b/gui_client/BrowserVidPlayer.cpp
@@ -114,22 +114,7 @@ static std::string makeEmbedHTMLForVideoURL(const std::string& video_url, int wi
const bool autoplay = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_AUTOPLAY);
const bool loop = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_LOOP);
const bool muted = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_MUTED);
- const bool sync_to_clock = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_SYNC_TO_CLOCK) && loop;
-
- const std::string sync_js = sync_to_clock ?
- "\t\t\tsetInterval(function() {\n"
- "\t\t\t\tif(!player || !player.getDuration) return;\n"
- "\t\t\t\tvar duration = player.getDuration();\n"
- "\t\t\t\tif(!(duration > 1.0)) return;\n"
- "\t\t\t\tvar target = (Date.now() * 0.001) % duration;\n"
- "\t\t\t\tvar cur = player.getCurrentTime();\n"
- "\t\t\t\tvar delta = target - cur;\n"
- "\t\t\t\tif(delta > duration * 0.5) delta -= duration;\n"
- "\t\t\t\tif(delta < -duration * 0.5) delta += duration;\n"
- "\t\t\t\tif(Math.abs(delta) > 1.0) player.seekTo(target, true);\n"
- "\t\t\t\tif(player.getPlayerState && player.getPlayerState() != YT.PlayerState.PLAYING) player.playVideo();\n"
- "\t\t\t}, 2000);\n"
- : "";
+
// See https://developers.google.com/youtube/player_parameters
const std::string player_params =
@@ -146,7 +131,6 @@ static std::string makeEmbedHTMLForVideoURL(const std::string& video_url, int wi
" events: { \n"
" onReady: function(e) { \n"
" " + (muted ? "e.target.mute();" : "") + "\n" // If video should be muted, insert a call to mute the video here.
- " " + sync_js +
" } \n"
" } \n"
"}";
@@ -491,7 +475,7 @@ EM_JS(void, applyYouTubeWatchPartyTargetTime, (int handle, double target_time, i
return;
let player = div.glare_yt_player;
- if(!player.getDuration || !player.getCurrentTime)
+ " \t\t\t\t\t\t\t\n"
return;
let duration = player.getDuration();
@@ -939,6 +923,9 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
{
createNewBrowserPlayer(gui_client, opengl_engine, ob);
this->state = State_BrowserCreated;
+ this->watch_party_state_requested = false;
+ this->joined_watch_party = false;
+ this->last_watch_party_sync_check_time = 0;
}
catch(glare::Exception& e)
{
@@ -958,6 +945,69 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
{
browser->think();
+ const URLString& video_url = ob->materials[0]->emission_texture_url;
+ const bool is_http_URL = hasPrefix(video_url, "http://") || hasPrefix(video_url, "https://");
+ if(is_http_URL)
+ {
+ const URL parsed_URL = URL::parseURL(toStdString(video_url));
+ const bool is_youtube = (parsed_URL.host == "www.youtube.com" || parsed_URL.host == "youtu.be");
+ const bool sync_enabled = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_SYNC_TO_CLOCK);
+
+ if(is_youtube && sync_enabled)
+ {
+ if(!watch_party_state_requested)
+ {
+ gui_client->requestVideoWatchPartyState(ob->uid);
+ watch_party_state_requested = true;
+ }
+
+ browser->executeJavaScript(
+ "if(!window.substrataSetWatchPartyButton){"
+ "window.substrataSetWatchPartyButton=function(text,visible){"
+ "var btn=document.getElementById('watchPartyButton');"
+ "if(!btn){btn=document.createElement('button');btn.id='watchPartyButton';"
+ "btn.style.position='fixed';btn.style.right='12px';btn.style.bottom='12px';btn.style.zIndex='20';"
+ "btn.style.display='none';btn.style.padding='8px 12px';btn.style.border='0';btn.style.borderRadius='6px';"
+ "btn.style.color='#fff';btn.style.background='rgba(0,0,0,0.7)';btn.style.font='600 13px sans-serif';btn.style.cursor='pointer';"
+ "btn.onclick=function(){if(!window.player||!window.player.getCurrentTime)return;"
+ "var action=(btn.textContent&&btn.textContent.indexOf('Join')>=0)?'join':'start';"
+ "var t=window.player.getCurrentTime();"
+ "window.location.href='https://localdomain/watchparty?action='+action+'&t='+encodeURIComponent(t.toString());};"
+ "document.body.appendChild(btn);}"
+ "btn.textContent=text;btn.style.display=visible?'block':'none';};"
+ "window.substrataApplyWatchPartyTargetTime=function(targetTime,forcePlay,loopMode){"
+ "if(!window.player||!window.player.getCurrentTime||!window.player.getDuration||!window.player.seekTo)return;"
+ "var duration=window.player.getDuration();if(!(duration>0.0))return;"
+ "var target=targetTime;"
+ "if(loopMode){target=((target%duration)+duration)%duration;}else{if(target<0.0)target=0.0;if(target>duration)target=duration;}"
+ "var cur=window.player.getCurrentTime();var delta=target-cur;"
+ "if(loopMode){if(delta>duration*0.5)delta-=duration;if(delta<-duration*0.5)delta+=duration;}"
+ "if(Math.abs(delta)>0.8)window.player.seekTo(target,true);"
+ "if(forcePlay&&window.player.getPlayerState&&window.player.getPlayerState()!=YT.PlayerState.PLAYING)window.player.playVideo();};"
+ "}");
+
+ const bool is_owner = (ob->video_watch_party_owner_user_id == gui_client->logged_in_user_id);
+ const bool show_button = !ob->video_watch_party_active || !is_owner;
+ const std::string button_text = ob->video_watch_party_active ? "Join watch party" : "Start watch party";
+ browser->executeJavaScript("if(window.substrataSetWatchPartyButton){window.substrataSetWatchPartyButton('" + button_text + "', " + toString(show_button ? 1 : 0) + ");}");
+
+ if(ob->video_watch_party_active && gui_client->world_state.nonNull() && !is_owner)
+ {
+ if(anim_time - last_watch_party_sync_check_time > 0.5)
+ {
+ last_watch_party_sync_check_time = anim_time;
+
+ const double cur_global_time = gui_client->world_state->getCurrentGlobalTime();
+ const double elapsed = myMax(0.0, cur_global_time - ob->video_watch_party_start_global_time);
+ const double target_time = ob->video_watch_party_start_video_time + elapsed;
+ const bool loop = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_LOOP);
+
+ browser->executeJavaScript("if(window.substrataApplyWatchPartyTargetTime){window.substrataApplyWatchPartyTargetTime(" + toString(target_time) + ", 1, " + toString(loop ? 1 : 0) + ");}");
+ }
+ }
+ }
+ }
+
const bool ob_visible = opengl_engine->isObjectInCameraFrustum(*ob->opengl_engine_ob);
if(ob_visible && !previous_is_visible) // If webview just became visible:
{
@@ -981,6 +1031,8 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
gui_client->logMessage("Closing vid player browser (out of view distance), video_URL: " + std::string(video_URL));
}
browser = NULL;
+ watch_party_state_requested = false;
+ joined_watch_party = false;
// Remove audio source
if(ob->audio_source.nonNull())
@@ -1004,6 +1056,10 @@ void BrowserVidPlayer::videoURLMayHaveChanged(GUIClient* gui_client, OpenGLEngin
const std::string video_URL = toStdString(ob->materials[0]->emission_texture_url);
if(video_URL != this->loaded_video_url)
{
+ watch_party_state_requested = false;
+ joined_watch_party = false;
+ last_watch_party_sync_check_time = 0;
+
if(browser.isNull())
{
try
diff --git a/gui_client/EmbeddedBrowser.cpp b/gui_client/EmbeddedBrowser.cpp
index eb1a61ace..ce1aae66f 100644
--- a/gui_client/EmbeddedBrowser.cpp
+++ b/gui_client/EmbeddedBrowser.cpp
@@ -636,6 +636,49 @@ class EmbeddedBrowserCefClient : public CefClient, public CefRequestHandler, pub
bool is_redirect) override
{
+ const std::string req_url = request->GetURL().ToString();
+ if(hasPrefix(req_url, "https://localdomain/watchparty"))
+ {
+ try
+ {
+ const URL parsed_url = URL::parseURL(req_url);
+ const std::map params = URL::parseQuery(parsed_url.query);
+
+ GUIClient* gui_client = NULL;
+ WorldObject* world_ob = NULL;
+ {
+ Lock lock(mutex);
+ gui_client = m_gui_client;
+ world_ob = m_ob;
+ }
+
+ if(gui_client && world_ob)
+ {
+ const auto action_it = params.find("action");
+ if(action_it != params.end())
+ {
+ if(action_it->second == "start")
+ {
+ double start_video_time = 0.0;
+ const auto t_it = params.find("t");
+ if(t_it != params.end())
+ start_video_time = stringToDouble(t_it->second);
+
+ gui_client->startVideoWatchParty(world_ob->uid, start_video_time);
+ }
+ else if(action_it->second == "join")
+ {
+ gui_client->requestVideoWatchPartyState(world_ob->uid);
+ }
+ }
+ }
+ }
+ catch(glare::Exception&)
+ {}
+
+ return true;
+ }
+
/*std::string frame_name = frame->GetName();
std::string frame_url = frame->GetURL();
@@ -1227,6 +1270,19 @@ void EmbeddedBrowser::navigate(const std::string& new_URL)
}
+void EmbeddedBrowser::executeJavaScript(const std::string& script)
+{
+#if CEF_SUPPORT
+ if(embedded_cef_browser && embedded_cef_browser->cef_browser)
+ {
+ CefRefPtr frame = embedded_cef_browser->cef_browser->GetMainFrame();
+ if(frame)
+ frame->ExecuteJavaScript(script, URL, 0);
+ }
+#endif
+}
+
+
void EmbeddedBrowser::browserBecameVisible()
{
#if CEF_SUPPORT
diff --git a/gui_client/EmbeddedBrowser.h b/gui_client/EmbeddedBrowser.h
index 8b2c2b97c..4b7452e72 100644
--- a/gui_client/EmbeddedBrowser.h
+++ b/gui_client/EmbeddedBrowser.h
@@ -42,6 +42,7 @@ class EmbeddedBrowser : public RefCounted
void updateRootPage(const std::string& root_page);
void navigate(const std::string& URL);
+ void executeJavaScript(const std::string& script);
void browserBecameVisible();
From 3c8ef104c34af55423fdebe0fd2d531af77d7ebe Mon Sep 17 00:00:00 2001
From: Micah Spaier <85509021+JerryBerry12@users.noreply.github.com>
Date: Sat, 21 Mar 2026 07:58:05 -0400
Subject: [PATCH 05/11] Compile fix
---
gui_client/BrowserVidPlayer.cpp | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gui_client/BrowserVidPlayer.cpp b/gui_client/BrowserVidPlayer.cpp
index a62dd22df..a64b502d7 100644
--- a/gui_client/BrowserVidPlayer.cpp
+++ b/gui_client/BrowserVidPlayer.cpp
@@ -986,7 +986,7 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
"if(forcePlay&&window.player.getPlayerState&&window.player.getPlayerState()!=YT.PlayerState.PLAYING)window.player.playVideo();};"
"}");
- const bool is_owner = (ob->video_watch_party_owner_user_id == gui_client->logged_in_user_id);
+ const bool is_owner = (ob->video_watch_party_owner_user_id == gui_client->logged_in_user_id.value());
const bool show_button = !ob->video_watch_party_active || !is_owner;
const std::string button_text = ob->video_watch_party_active ? "Join watch party" : "Start watch party";
browser->executeJavaScript("if(window.substrataSetWatchPartyButton){window.substrataSetWatchPartyButton('" + button_text + "', " + toString(show_button ? 1 : 0) + ");}");
From d25ebb0e971af3da86b9551be8ba7eb9c8e57a71 Mon Sep 17 00:00:00 2001
From: Micah Spaier <85509021+JerryBerry12@users.noreply.github.com>
Date: Sat, 21 Mar 2026 14:36:02 -0400
Subject: [PATCH 06/11] Improve watch party stuff Make sync actually work Make
it so that when a client is in a watch party, they can't control the video
Add Watch Party overlay so its clear we're in a watch party
---
gui_client/BrowserVidPlayer.cpp | 146 ++++++++++++++++++++++++++------
gui_client/ClientThread.cpp | 2 +
gui_client/EmbeddedBrowser.cpp | 9 +-
gui_client/GUIClient.cpp | 3 +-
gui_client/GUIClient.h | 2 +-
server/ServerWorldState.h | 3 +-
server/WorkerThread.cpp | 14 ++-
shared/WorldObject.cpp | 2 +
shared/WorldObject.h | 1 +
9 files changed, 150 insertions(+), 32 deletions(-)
diff --git a/gui_client/BrowserVidPlayer.cpp b/gui_client/BrowserVidPlayer.cpp
index a64b502d7..5ae7b52ff 100644
--- a/gui_client/BrowserVidPlayer.cpp
+++ b/gui_client/BrowserVidPlayer.cpp
@@ -469,14 +469,65 @@ EM_JS(double, getYouTubeCurrentTime, (int handle), {
});
+EM_JS(int, getYouTubeIsPlaying, (int handle), {
+ let div = html_view_elem_handle_to_div_map[handle];
+ if(!div || !div.glare_yt_player || !div.glare_yt_player.getPlayerState)
+ return 0;
+
+ return (div.glare_yt_player.getPlayerState() == YT.PlayerState.PLAYING) ? 1 : 0;
+});
+
+
+EM_JS(void, setYouTubeWatchPartyOverlay, (int handle, const char* text, int visible, int lock_controls), {
+ let div = html_view_elem_handle_to_div_map[handle];
+ if(!div)
+ return;
+
+ if(!div.glare_watch_party_overlay)
+ {
+ let overlay = document.createElement('div');
+ overlay.style.position = 'absolute';
+ overlay.style.left = '12px';
+ overlay.style.top = '12px';
+ overlay.style.zIndex = '12';
+ overlay.style.padding = '8px 12px';
+ overlay.style.background = 'rgba(8,8,8,0.80)';
+ overlay.style.color = '#fff';
+ overlay.style.border = '1px solid rgba(255,255,255,0.2)';
+ overlay.style.borderRadius = '6px';
+ overlay.style.font = '600 13px sans-serif';
+ overlay.style.pointerEvents = 'none';
+ div.glare_watch_party_overlay = overlay;
+ div.appendChild(overlay);
+ }
+
+ if(!div.glare_watch_party_blocker)
+ {
+ let blocker = document.createElement('div');
+ blocker.style.position = 'absolute';
+ blocker.style.left = '0';
+ blocker.style.top = '0';
+ blocker.style.right = '0';
+ blocker.style.bottom = '0';
+ blocker.style.zIndex = '11';
+ blocker.style.background = 'rgba(0,0,0,0)';
+ blocker.style.display = 'none';
+ div.glare_watch_party_blocker = blocker;
+ div.appendChild(blocker);
+ }
+
+ div.glare_watch_party_overlay.innerText = UTF8ToString(text);
+ div.glare_watch_party_overlay.style.display = (visible ? 'block' : 'none');
+ div.glare_watch_party_blocker.style.display = (lock_controls ? 'block' : 'none');
+});
+
+
EM_JS(void, applyYouTubeWatchPartyTargetTime, (int handle, double target_time, int should_play, int loop), {
let div = html_view_elem_handle_to_div_map[handle];
if(!div || !div.glare_yt_player)
return;
let player = div.glare_yt_player;
- " \t\t\t\t\t\t\t\n"
- return;
let duration = player.getDuration();
if(!(duration > 0.1))
@@ -837,30 +888,41 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
watch_party_state_requested = true;
}
- if(joined_watch_party)
- setYouTubeWatchPartyButton(html_view_handle, "Join watch party", 0);
- else if(ob->video_watch_party_active)
- setYouTubeWatchPartyButton(html_view_handle, "Join watch party", 1);
+ const bool is_owner = (ob->video_watch_party_owner_user_id == gui_client->logged_in_user_id.value());
+
+ setYouTubeWatchPartyButton(html_view_handle, "Start watch party", ob->video_watch_party_active ? 0 : 1);
+ if(ob->video_watch_party_active)
+ {
+ const bool lock_controls = !is_owner;
+ setYouTubeWatchPartyOverlay(html_view_handle, is_owner ? "Watch party: You are host" : "Watch party: Synced to host", 1, lock_controls ? 1 : 0);
+ }
else
- setYouTubeWatchPartyButton(html_view_handle, "Start watch party", 1);
+ {
+ setYouTubeWatchPartyOverlay(html_view_handle, "", 0, 0);
+ }
const int action = consumeYouTubeWatchPartyAction(html_view_handle);
if(action == 1) // Start watch party
{
const double start_video_time = getYouTubeCurrentTime(html_view_handle);
- gui_client->startVideoWatchParty(ob->uid, start_video_time);
- joined_watch_party = true;
- gui_client->requestVideoWatchPartyState(ob->uid);
- }
- else if(action == 2) // Join watch party
- {
- joined_watch_party = true;
+ const bool is_playing = (getYouTubeIsPlaying(html_view_handle) != 0);
+ gui_client->startVideoWatchParty(ob->uid, start_video_time, is_playing);
gui_client->requestVideoWatchPartyState(ob->uid);
}
- if(joined_watch_party && ob->video_watch_party_active && gui_client->world_state.nonNull())
+ if(ob->video_watch_party_active && gui_client->world_state.nonNull())
{
- if(anim_time - last_watch_party_sync_check_time > 0.5)
+ if(is_owner)
+ {
+ if(anim_time - last_watch_party_sync_check_time > 0.25)
+ {
+ last_watch_party_sync_check_time = anim_time;
+ const double start_video_time = getYouTubeCurrentTime(html_view_handle);
+ const bool is_playing = (getYouTubeIsPlaying(html_view_handle) != 0);
+ gui_client->startVideoWatchParty(ob->uid, start_video_time, is_playing);
+ }
+ }
+ else if(anim_time - last_watch_party_sync_check_time > 0.2)
{
last_watch_party_sync_check_time = anim_time;
@@ -868,11 +930,12 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
const double elapsed = myMax(0.0, cur_global_time - ob->video_watch_party_start_global_time);
const double target_time = ob->video_watch_party_start_video_time + elapsed;
const bool loop = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_LOOP);
+ const int should_play = ob->video_watch_party_is_playing ? 1 : 0;
if(loop)
- applyYouTubeWatchPartyTargetTime(html_view_handle, target_time, 1, 1);
+ applyYouTubeWatchPartyTargetTime(html_view_handle, target_time, should_play, 1);
else
- applyYouTubeWatchPartyTargetTime(html_view_handle, target_time, 1, 0);
+ applyYouTubeWatchPartyTargetTime(html_view_handle, target_time, should_play, 0);
}
}
}
@@ -970,11 +1033,26 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
"btn.style.display='none';btn.style.padding='8px 12px';btn.style.border='0';btn.style.borderRadius='6px';"
"btn.style.color='#fff';btn.style.background='rgba(0,0,0,0.7)';btn.style.font='600 13px sans-serif';btn.style.cursor='pointer';"
"btn.onclick=function(){if(!window.player||!window.player.getCurrentTime)return;"
- "var action=(btn.textContent&&btn.textContent.indexOf('Join')>=0)?'join':'start';"
+ "var action='start';"
"var t=window.player.getCurrentTime();"
- "window.location.href='https://localdomain/watchparty?action='+action+'&t='+encodeURIComponent(t.toString());};"
+ "var p=(window.player.getPlayerState&&window.player.getPlayerState()==YT.PlayerState.PLAYING)?1:0;"
+ "window.location.href='https://localdomain/watchparty?action='+action+'&t='+encodeURIComponent(t.toString())+'&p='+p;};"
"document.body.appendChild(btn);}"
"btn.textContent=text;btn.style.display=visible?'block':'none';};"
+ "window.substrataSetWatchPartyOverlay=function(text,visible,lockControls){"
+ "var ov=document.getElementById('watchPartyOverlay');"
+ "if(!ov){ov=document.createElement('div');ov.id='watchPartyOverlay';"
+ "ov.style.position='fixed';ov.style.left='12px';ov.style.top='12px';ov.style.zIndex='21';"
+ "ov.style.padding='8px 12px';ov.style.borderRadius='6px';ov.style.background='rgba(10,10,10,0.8)';"
+ "ov.style.border='1px solid rgba(255,255,255,0.2)';ov.style.color='#fff';ov.style.font='600 13px sans-serif';ov.style.pointerEvents='none';"
+ "document.body.appendChild(ov);}"
+ "var blocker=document.getElementById('watchPartyBlocker');"
+ "if(!blocker){blocker=document.createElement('div');blocker.id='watchPartyBlocker';"
+ "blocker.style.position='fixed';blocker.style.left='0';blocker.style.top='0';blocker.style.right='0';blocker.style.bottom='0';blocker.style.zIndex='19';"
+ "blocker.style.background='rgba(0,0,0,0)';document.body.appendChild(blocker);}"
+ "ov.textContent=text;ov.style.display=visible?'block':'none';"
+ "blocker.style.display=lockControls?'block':'none';};"
+ "window.substrataSetWatchPartyOwnerActive=function(isOwner,isActive){window.substrataWatchPartyIsOwner=isOwner?1:0;window.substrataWatchPartyActive=isActive?1:0;};"
"window.substrataApplyWatchPartyTargetTime=function(targetTime,forcePlay,loopMode){"
"if(!window.player||!window.player.getCurrentTime||!window.player.getDuration||!window.player.seekTo)return;"
"var duration=window.player.getDuration();if(!(duration>0.0))return;"
@@ -982,18 +1060,33 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
"if(loopMode){target=((target%duration)+duration)%duration;}else{if(target<0.0)target=0.0;if(target>duration)target=duration;}"
"var cur=window.player.getCurrentTime();var delta=target-cur;"
"if(loopMode){if(delta>duration*0.5)delta-=duration;if(delta<-duration*0.5)delta+=duration;}"
- "if(Math.abs(delta)>0.8)window.player.seekTo(target,true);"
- "if(forcePlay&&window.player.getPlayerState&&window.player.getPlayerState()!=YT.PlayerState.PLAYING)window.player.playVideo();};"
+ "if(Math.abs(delta)>0.25)window.player.seekTo(target,true);"
+ "if(forcePlay){if(window.player.getPlayerState&&window.player.getPlayerState()!=YT.PlayerState.PLAYING)window.player.playVideo();}"
+ "else{if(window.player.pauseVideo)window.player.pauseVideo();}};"
+ "if(!window.substrataWatchPartyHeartbeatTimer){window.substrataWatchPartyHeartbeatTimer=setInterval(function(){"
+ "if(!window.substrataWatchPartyActive||!window.substrataWatchPartyIsOwner||!window.player||!window.player.getCurrentTime)return;"
+ "var t=window.player.getCurrentTime();"
+ "var p=(window.player.getPlayerState&&window.player.getPlayerState()==YT.PlayerState.PLAYING)?1:0;"
+ "window.location.href='https://localdomain/watchparty?action=update&t='+encodeURIComponent(t.toString())+'&p='+p;"
+ "},250);}"
"}");
const bool is_owner = (ob->video_watch_party_owner_user_id == gui_client->logged_in_user_id.value());
- const bool show_button = !ob->video_watch_party_active || !is_owner;
- const std::string button_text = ob->video_watch_party_active ? "Join watch party" : "Start watch party";
+ const bool show_button = !ob->video_watch_party_active;
+ const std::string button_text = "Start watch party";
browser->executeJavaScript("if(window.substrataSetWatchPartyButton){window.substrataSetWatchPartyButton('" + button_text + "', " + toString(show_button ? 1 : 0) + ");}");
+ browser->executeJavaScript("if(window.substrataSetWatchPartyOwnerActive){window.substrataSetWatchPartyOwnerActive(" + toString(is_owner ? 1 : 0) + ", " + toString(ob->video_watch_party_active ? 1 : 0) + ");}");
+ if(ob->video_watch_party_active)
+ {
+ const bool lock_controls = !is_owner;
+ browser->executeJavaScript("if(window.substrataSetWatchPartyOverlay){window.substrataSetWatchPartyOverlay('" + std::string(is_owner ? "Watch party: You are host" : "Watch party: Synced to host") + "', 1, " + toString(lock_controls ? 1 : 0) + ");}");
+ }
+ else
+ browser->executeJavaScript("if(window.substrataSetWatchPartyOverlay){window.substrataSetWatchPartyOverlay('', 0, 0);}");
if(ob->video_watch_party_active && gui_client->world_state.nonNull() && !is_owner)
{
- if(anim_time - last_watch_party_sync_check_time > 0.5)
+ if(anim_time - last_watch_party_sync_check_time > 0.2)
{
last_watch_party_sync_check_time = anim_time;
@@ -1001,8 +1094,9 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
const double elapsed = myMax(0.0, cur_global_time - ob->video_watch_party_start_global_time);
const double target_time = ob->video_watch_party_start_video_time + elapsed;
const bool loop = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_LOOP);
+ const int should_play = ob->video_watch_party_is_playing ? 1 : 0;
- browser->executeJavaScript("if(window.substrataApplyWatchPartyTargetTime){window.substrataApplyWatchPartyTargetTime(" + toString(target_time) + ", 1, " + toString(loop ? 1 : 0) + ");}");
+ browser->executeJavaScript("if(window.substrataApplyWatchPartyTargetTime){window.substrataApplyWatchPartyTargetTime(" + toString(target_time) + ", " + toString(should_play) + ", " + toString(loop ? 1 : 0) + ");}");
}
}
}
diff --git a/gui_client/ClientThread.cpp b/gui_client/ClientThread.cpp
index bb6015336..c688d5f96 100644
--- a/gui_client/ClientThread.cpp
+++ b/gui_client/ClientThread.cpp
@@ -913,6 +913,7 @@ void ClientThread::readAndHandleMessage(const uint32 peer_protocol_version)
const uint32 owner_user_id = msg_buffer.readUInt32();
const double start_global_time = msg_buffer.readDouble();
const double start_video_time = msg_buffer.readDouble();
+ const bool is_playing = msg_buffer.readUInt32() != 0;
{
Lock lock(world_state->mutex);
@@ -924,6 +925,7 @@ void ClientThread::readAndHandleMessage(const uint32 peer_protocol_version)
ob->video_watch_party_owner_user_id = owner_user_id;
ob->video_watch_party_start_global_time = start_global_time;
ob->video_watch_party_start_video_time = start_video_time;
+ ob->video_watch_party_is_playing = is_playing;
ob->from_remote_video_watch_party_dirty = true;
world_state->dirty_from_remote_objects.insert(ob);
}
diff --git a/gui_client/EmbeddedBrowser.cpp b/gui_client/EmbeddedBrowser.cpp
index ce1aae66f..e5b77c6ec 100644
--- a/gui_client/EmbeddedBrowser.cpp
+++ b/gui_client/EmbeddedBrowser.cpp
@@ -657,14 +657,19 @@ class EmbeddedBrowserCefClient : public CefClient, public CefRequestHandler, pub
const auto action_it = params.find("action");
if(action_it != params.end())
{
- if(action_it->second == "start")
+ if(action_it->second == "start" || action_it->second == "update")
{
double start_video_time = 0.0;
const auto t_it = params.find("t");
if(t_it != params.end())
start_video_time = stringToDouble(t_it->second);
- gui_client->startVideoWatchParty(world_ob->uid, start_video_time);
+ bool is_playing = true;
+ const auto p_it = params.find("p");
+ if(p_it != params.end())
+ is_playing = (p_it->second == "1");
+
+ gui_client->startVideoWatchParty(world_ob->uid, start_video_time, is_playing);
}
else if(action_it->second == "join")
{
diff --git a/gui_client/GUIClient.cpp b/gui_client/GUIClient.cpp
index 703fed1f6..956338181 100644
--- a/gui_client/GUIClient.cpp
+++ b/gui_client/GUIClient.cpp
@@ -12543,7 +12543,7 @@ void GUIClient::requestVideoWatchPartyState(const UID& object_uid)
}
-void GUIClient::startVideoWatchParty(const UID& object_uid, double start_video_time)
+void GUIClient::startVideoWatchParty(const UID& object_uid, double start_video_time, bool is_playing)
{
if(this->connection_state != ServerConnectionState_Connected || this->client_thread.isNull())
return;
@@ -12551,6 +12551,7 @@ void GUIClient::startVideoWatchParty(const UID& object_uid, double start_video_t
MessageUtils::initPacket(scratch_packet, Protocol::VideoWatchPartyStart);
writeToStream(object_uid, scratch_packet);
scratch_packet.writeDouble(start_video_time);
+ scratch_packet.writeUInt32(is_playing ? 1u : 0u);
enqueueMessageToSend(*this->client_thread, scratch_packet);
}
diff --git a/gui_client/GUIClient.h b/gui_client/GUIClient.h
index 34bf54eda..22aa43308 100644
--- a/gui_client/GUIClient.h
+++ b/gui_client/GUIClient.h
@@ -244,7 +244,7 @@ class GUIClient : public ObLoadingCallbacks, public PrintOutput, public PhysicsW
void updateGroundPlane();
void sendLightmapNeededFlagsSlot();
void requestVideoWatchPartyState(const UID& object_uid);
- void startVideoWatchParty(const UID& object_uid, double start_video_time);
+ void startVideoWatchParty(const UID& object_uid, double start_video_time, bool is_playing);
void useActionTriggered(bool use_mouse_cursor); // if use_mouse_cursor is false, use crosshair as cursor instead.
void loginButtonClicked();
void signupButtonClicked();
diff --git a/server/ServerWorldState.h b/server/ServerWorldState.h
index 2a2d14975..a7996dee7 100644
--- a/server/ServerWorldState.h
+++ b/server/ServerWorldState.h
@@ -223,11 +223,12 @@ class ServerWorldState : public ThreadSafeRefCounted
struct VideoWatchPartyState
{
- VideoWatchPartyState() : active(false), owner_user_id(std::numeric_limits::max()), start_global_time(0), start_video_time(0) {}
+ VideoWatchPartyState() : active(false), owner_user_id(std::numeric_limits::max()), start_global_time(0), start_video_time(0), is_playing(true) {}
bool active;
uint32 owner_user_id;
double start_global_time;
double start_video_time;
+ bool is_playing;
};
void addParcelAsDBDirty (const ParcelRef parcel, WorldStateLock& /*world_state_lock*/) { db_dirty_parcels.insert(parcel); }
diff --git a/server/WorkerThread.cpp b/server/WorkerThread.cpp
index e4db07727..39d46fefd 100644
--- a/server/WorkerThread.cpp
+++ b/server/WorkerThread.cpp
@@ -2204,6 +2204,7 @@ void WorkerThread::doRun()
{
const UID object_uid = readUIDFromStream(msg_buffer);
const double requested_start_video_time = msg_buffer.readDouble();
+ const bool requested_is_playing = (msg_buffer.readUInt32() != 0);
ServerWorldState::VideoWatchPartyState state_to_send;
bool should_send = false;
@@ -2217,12 +2218,21 @@ void WorkerThread::doRun()
if(ob->object_type == WorldObject::ObjectType_Video && BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_SYNC_TO_CLOCK))
{
auto& state = cur_world_state->getVideoWatchParties(lock)[object_uid];
+ const uint32 request_user_id = client_user_id.valid() ? client_user_id.value() : std::numeric_limits::max();
+
if(!state.active)
{
state.active = true;
- state.owner_user_id = client_user_id.valid() ? client_user_id.value() : std::numeric_limits::max();
+ state.owner_user_id = request_user_id;
+ state.start_global_time = server->getCurrentGlobalTime();
+ state.start_video_time = myMax(0.0, requested_start_video_time);
+ state.is_playing = requested_is_playing;
+ }
+ else if(state.owner_user_id == request_user_id)
+ {
state.start_global_time = server->getCurrentGlobalTime();
state.start_video_time = myMax(0.0, requested_start_video_time);
+ state.is_playing = requested_is_playing;
}
state_to_send = state;
@@ -2239,6 +2249,7 @@ void WorkerThread::doRun()
scratch_packet.writeUInt32(state_to_send.owner_user_id);
scratch_packet.writeDouble(state_to_send.start_global_time);
scratch_packet.writeDouble(state_to_send.start_video_time);
+ scratch_packet.writeUInt32(state_to_send.is_playing ? 1u : 0u);
MessageUtils::updatePacketLengthField(scratch_packet);
enqueuePacketToBroadcast(scratch_packet);
}
@@ -2263,6 +2274,7 @@ void WorkerThread::doRun()
scratch_packet.writeUInt32(state_to_send.owner_user_id);
scratch_packet.writeDouble(state_to_send.start_global_time);
scratch_packet.writeDouble(state_to_send.start_video_time);
+ scratch_packet.writeUInt32(state_to_send.is_playing ? 1u : 0u);
MessageUtils::updatePacketLengthField(scratch_packet);
enqueueDataToSend(scratch_packet);
diff --git a/shared/WorldObject.cpp b/shared/WorldObject.cpp
index 7af2b3140..b45592e4e 100644
--- a/shared/WorldObject.cpp
+++ b/shared/WorldObject.cpp
@@ -131,6 +131,7 @@ WorldObject::WorldObject() noexcept
video_watch_party_owner_user_id = std::numeric_limits::max();
video_watch_party_start_global_time = 0;
video_watch_party_start_video_time = 0;
+ video_watch_party_is_playing = true;
transmission_time_offset = 0;
@@ -1043,6 +1044,7 @@ void WorldObject::copyNetworkStateFrom(const WorldObject& other)
video_watch_party_owner_user_id = other.video_watch_party_owner_user_id;
video_watch_party_start_global_time = other.video_watch_party_start_global_time;
video_watch_party_start_video_time = other.video_watch_party_start_video_time;
+ video_watch_party_is_playing = other.video_watch_party_is_playing;
diff --git a/shared/WorldObject.h b/shared/WorldObject.h
index cccc94909..9c6a8926e 100644
--- a/shared/WorldObject.h
+++ b/shared/WorldObject.h
@@ -424,6 +424,7 @@ class WorldObject // : public ThreadSafeRefCounted
uint32 video_watch_party_owner_user_id;
double video_watch_party_start_global_time;
double video_watch_party_start_video_time;
+ bool video_watch_party_is_playing;
static const uint32 AUDIO_SOURCE_URL_CHANGED = 1; // Set when audio_source_url is changed
static const uint32 SCRIPT_CHANGED = 2; // Set when script is changed
From 92b70e4b94cd50d92f5add73df15ebb684782342 Mon Sep 17 00:00:00 2001
From: Micah Spaier <85509021+JerryBerry12@users.noreply.github.com>
Date: Sun, 22 Mar 2026 18:59:32 -0400
Subject: [PATCH 07/11] Fix Read past end buffer errors
---
gui_client/ClientThread.cpp | 8 +++++++-
server/WorkerThread.cpp | 8 +++++++-
2 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/gui_client/ClientThread.cpp b/gui_client/ClientThread.cpp
index c688d5f96..95782fae2 100644
--- a/gui_client/ClientThread.cpp
+++ b/gui_client/ClientThread.cpp
@@ -913,7 +913,13 @@ void ClientThread::readAndHandleMessage(const uint32 peer_protocol_version)
const uint32 owner_user_id = msg_buffer.readUInt32();
const double start_global_time = msg_buffer.readDouble();
const double start_video_time = msg_buffer.readDouble();
- const bool is_playing = msg_buffer.readUInt32() != 0;
+ bool is_playing = true;
+ try
+ {
+ is_playing = (msg_buffer.readUInt32() != 0); // Optional, for backward compatibility with older server packets.
+ }
+ catch(glare::Exception&)
+ {}
{
Lock lock(world_state->mutex);
diff --git a/server/WorkerThread.cpp b/server/WorkerThread.cpp
index 39d46fefd..2198e5059 100644
--- a/server/WorkerThread.cpp
+++ b/server/WorkerThread.cpp
@@ -2204,7 +2204,13 @@ void WorkerThread::doRun()
{
const UID object_uid = readUIDFromStream(msg_buffer);
const double requested_start_video_time = msg_buffer.readDouble();
- const bool requested_is_playing = (msg_buffer.readUInt32() != 0);
+ bool requested_is_playing = true;
+ try
+ {
+ requested_is_playing = (msg_buffer.readUInt32() != 0); // Optional, for backward compatibility with older client packets.
+ }
+ catch(glare::Exception&)
+ {}
ServerWorldState::VideoWatchPartyState state_to_send;
bool should_send = false;
From 47a7ab9f428f0d644aaec692855d6eba5c5a099f Mon Sep 17 00:00:00 2001
From: Micah Spaier <85509021+JerryBerry12@users.noreply.github.com>
Date: Sun, 22 Mar 2026 20:12:33 -0400
Subject: [PATCH 08/11] general bug fixes
---
gui_client/BrowserVidPlayer.cpp | 70 +++++++++++++++------------------
1 file changed, 31 insertions(+), 39 deletions(-)
diff --git a/gui_client/BrowserVidPlayer.cpp b/gui_client/BrowserVidPlayer.cpp
index 5ae7b52ff..600948f94 100644
--- a/gui_client/BrowserVidPlayer.cpp
+++ b/gui_client/BrowserVidPlayer.cpp
@@ -381,30 +381,6 @@ EM_JS(int, makeYouTubeHTMLView, (const char* video_id, int autoplay, int loop, i
//console.log("****************player onReady()****************");
if(muted) { e.target.mute(); } // If video should be muted, insert a call to mute the video here.
e.target.playVideo();
-
- if(sync_to_clock)
- {
- setInterval(function() {
- if(!player || !player.getDuration)
- return;
-
- let duration = player.getDuration();
- if(!(duration > 1.0))
- return;
-
- let target = (Date.now() * 0.001) % duration;
- let cur = player.getCurrentTime();
- let delta = target - cur;
- if(delta > duration * 0.5) delta -= duration;
- if(delta < -duration * 0.5) delta += duration;
-
- if(Math.abs(delta) > 1.0)
- player.seekTo(target, true);
-
- if(player.getPlayerState && player.getPlayerState() != YT.PlayerState.PLAYING)
- player.playVideo();
- }, 2000);
- }
}
}
};
@@ -890,11 +866,14 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
const bool is_owner = (ob->video_watch_party_owner_user_id == gui_client->logged_in_user_id.value());
- setYouTubeWatchPartyButton(html_view_handle, "Start watch party", ob->video_watch_party_active ? 0 : 1);
+ if(ob->video_watch_party_active && !is_owner && !joined_watch_party)
+ setYouTubeWatchPartyButton(html_view_handle, "Join watch party", 1);
+ else
+ setYouTubeWatchPartyButton(html_view_handle, "Start watch party", ob->video_watch_party_active ? 0 : 1);
if(ob->video_watch_party_active)
{
- const bool lock_controls = !is_owner;
- setYouTubeWatchPartyOverlay(html_view_handle, is_owner ? "Watch party: You are host" : "Watch party: Synced to host", 1, lock_controls ? 1 : 0);
+ const bool lock_controls = (!is_owner && joined_watch_party);
+ setYouTubeWatchPartyOverlay(html_view_handle, is_owner ? "Watch party: You are host" : (joined_watch_party ? "Watch party: Synced to host" : "Watch party available"), 1, lock_controls ? 1 : 0);
}
else
{
@@ -907,6 +886,12 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
const double start_video_time = getYouTubeCurrentTime(html_view_handle);
const bool is_playing = (getYouTubeIsPlaying(html_view_handle) != 0);
gui_client->startVideoWatchParty(ob->uid, start_video_time, is_playing);
+ joined_watch_party = true;
+ gui_client->requestVideoWatchPartyState(ob->uid);
+ }
+ else if(action == 2) // Join watch party
+ {
+ joined_watch_party = true;
gui_client->requestVideoWatchPartyState(ob->uid);
}
@@ -922,13 +907,13 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
gui_client->startVideoWatchParty(ob->uid, start_video_time, is_playing);
}
}
- else if(anim_time - last_watch_party_sync_check_time > 0.2)
+ else if(joined_watch_party && anim_time - last_watch_party_sync_check_time > 0.2)
{
last_watch_party_sync_check_time = anim_time;
const double cur_global_time = gui_client->world_state->getCurrentGlobalTime();
const double elapsed = myMax(0.0, cur_global_time - ob->video_watch_party_start_global_time);
- const double target_time = ob->video_watch_party_start_video_time + elapsed;
+ const double target_time = ob->video_watch_party_is_playing ? (ob->video_watch_party_start_video_time + elapsed) : ob->video_watch_party_start_video_time;
const bool loop = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_LOOP);
const int should_play = ob->video_watch_party_is_playing ? 1 : 0;
@@ -1033,11 +1018,13 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
"btn.style.display='none';btn.style.padding='8px 12px';btn.style.border='0';btn.style.borderRadius='6px';"
"btn.style.color='#fff';btn.style.background='rgba(0,0,0,0.7)';btn.style.font='600 13px sans-serif';btn.style.cursor='pointer';"
"btn.onclick=function(){if(!window.player||!window.player.getCurrentTime)return;"
- "var action='start';"
+ "var action=(btn.textContent&&btn.textContent.indexOf('Join')>=0)?'join':'start';"
+ "if(action==='join')window.substrataWatchPartyJoined=1;"
"var t=window.player.getCurrentTime();"
"var p=(window.player.getPlayerState&&window.player.getPlayerState()==YT.PlayerState.PLAYING)?1:0;"
"window.location.href='https://localdomain/watchparty?action='+action+'&t='+encodeURIComponent(t.toString())+'&p='+p;};"
"document.body.appendChild(btn);}"
+ "if(window.substrataWatchPartyJoined&&text.indexOf('Join')>=0)visible=false;"
"btn.textContent=text;btn.style.display=visible?'block':'none';};"
"window.substrataSetWatchPartyOverlay=function(text,visible,lockControls){"
"var ov=document.getElementById('watchPartyOverlay');"
@@ -1051,9 +1038,13 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
"blocker.style.position='fixed';blocker.style.left='0';blocker.style.top='0';blocker.style.right='0';blocker.style.bottom='0';blocker.style.zIndex='19';"
"blocker.style.background='rgba(0,0,0,0)';document.body.appendChild(blocker);}"
"ov.textContent=text;ov.style.display=visible?'block':'none';"
- "blocker.style.display=lockControls?'block':'none';};"
- "window.substrataSetWatchPartyOwnerActive=function(isOwner,isActive){window.substrataWatchPartyIsOwner=isOwner?1:0;window.substrataWatchPartyActive=isActive?1:0;};"
+ "var effectiveLock=lockControls&&(window.substrataWatchPartyIsOwner?0:window.substrataWatchPartyJoined?1:0);"
+ "blocker.style.display=effectiveLock?'block':'none';};"
+ "window.substrataSetWatchPartyOwnerActive=function(isOwner,isActive){"
+ "window.substrataWatchPartyIsOwner=isOwner?1:0;window.substrataWatchPartyActive=isActive?1:0;"
+ "if(!isActive)window.substrataWatchPartyJoined=0;};"
"window.substrataApplyWatchPartyTargetTime=function(targetTime,forcePlay,loopMode){"
+ "if(!window.substrataWatchPartyIsOwner&&!window.substrataWatchPartyJoined)return;"
"if(!window.player||!window.player.getCurrentTime||!window.player.getDuration||!window.player.seekTo)return;"
"var duration=window.player.getDuration();if(!(duration>0.0))return;"
"var target=targetTime;"
@@ -1063,23 +1054,24 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
"if(Math.abs(delta)>0.25)window.player.seekTo(target,true);"
"if(forcePlay){if(window.player.getPlayerState&&window.player.getPlayerState()!=YT.PlayerState.PLAYING)window.player.playVideo();}"
"else{if(window.player.pauseVideo)window.player.pauseVideo();}};"
- "if(!window.substrataWatchPartyHeartbeatTimer){window.substrataWatchPartyHeartbeatTimer=setInterval(function(){"
+ "if(!window.substrataWatchPartyHeartbeatTimer){window.substrataWatchPartyLastSentT=-1;window.substrataWatchPartyLastSentP=-1;window.substrataWatchPartyHeartbeatTimer=setInterval(function(){"
"if(!window.substrataWatchPartyActive||!window.substrataWatchPartyIsOwner||!window.player||!window.player.getCurrentTime)return;"
"var t=window.player.getCurrentTime();"
"var p=(window.player.getPlayerState&&window.player.getPlayerState()==YT.PlayerState.PLAYING)?1:0;"
- "window.location.href='https://localdomain/watchparty?action=update&t='+encodeURIComponent(t.toString())+'&p='+p;"
- "},250);}"
+ "var shouldSend=(p!=window.substrataWatchPartyLastSentP)||((p==1)&&Math.abs(t-window.substrataWatchPartyLastSentT)>1.0);"
+ "if(shouldSend){window.substrataWatchPartyLastSentT=t;window.substrataWatchPartyLastSentP=p;window.location.href='https://localdomain/watchparty?action=update&t='+encodeURIComponent(t.toString())+'&p='+p;}"
+ "},1000);}"
"}");
const bool is_owner = (ob->video_watch_party_owner_user_id == gui_client->logged_in_user_id.value());
- const bool show_button = !ob->video_watch_party_active;
- const std::string button_text = "Start watch party";
+ const bool show_button = !ob->video_watch_party_active || !is_owner;
+ const std::string button_text = ob->video_watch_party_active ? "Join watch party" : "Start watch party";
browser->executeJavaScript("if(window.substrataSetWatchPartyButton){window.substrataSetWatchPartyButton('" + button_text + "', " + toString(show_button ? 1 : 0) + ");}");
browser->executeJavaScript("if(window.substrataSetWatchPartyOwnerActive){window.substrataSetWatchPartyOwnerActive(" + toString(is_owner ? 1 : 0) + ", " + toString(ob->video_watch_party_active ? 1 : 0) + ");}");
if(ob->video_watch_party_active)
{
const bool lock_controls = !is_owner;
- browser->executeJavaScript("if(window.substrataSetWatchPartyOverlay){window.substrataSetWatchPartyOverlay('" + std::string(is_owner ? "Watch party: You are host" : "Watch party: Synced to host") + "', 1, " + toString(lock_controls ? 1 : 0) + ");}");
+ browser->executeJavaScript("if(window.substrataSetWatchPartyOverlay){window.substrataSetWatchPartyOverlay('" + std::string(is_owner ? "Watch party: You are host" : "Watch party available") + "', 1, " + toString(lock_controls ? 1 : 0) + ");}");
}
else
browser->executeJavaScript("if(window.substrataSetWatchPartyOverlay){window.substrataSetWatchPartyOverlay('', 0, 0);}");
@@ -1092,7 +1084,7 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
const double cur_global_time = gui_client->world_state->getCurrentGlobalTime();
const double elapsed = myMax(0.0, cur_global_time - ob->video_watch_party_start_global_time);
- const double target_time = ob->video_watch_party_start_video_time + elapsed;
+ const double target_time = ob->video_watch_party_is_playing ? (ob->video_watch_party_start_video_time + elapsed) : ob->video_watch_party_start_video_time;
const bool loop = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_LOOP);
const int should_play = ob->video_watch_party_is_playing ? 1 : 0;
From 170c8c417991b0d2a0b2a60fbf2d3913dbab0a44 Mon Sep 17 00:00:00 2001
From: Micah Spaier <85509021+JerryBerry12@users.noreply.github.com>
Date: Sun, 22 Mar 2026 21:35:51 -0400
Subject: [PATCH 09/11] Change non-host joining to automatically Add "Watch
party: synced" when you're in a watch party Reduce agrressiveness of
correction to avoid jitter on lower-powered systems Make it so that non-hosts
cannot control the video
---
gui_client/BrowserVidPlayer.cpp | 41 ++++++++++++++-------------------
1 file changed, 17 insertions(+), 24 deletions(-)
diff --git a/gui_client/BrowserVidPlayer.cpp b/gui_client/BrowserVidPlayer.cpp
index 600948f94..2ff5f7efa 100644
--- a/gui_client/BrowserVidPlayer.cpp
+++ b/gui_client/BrowserVidPlayer.cpp
@@ -521,7 +521,7 @@ EM_JS(void, applyYouTubeWatchPartyTargetTime, (int handle, double target_time, i
clamped_target = Math.max(0.0, Math.min(target_time, duration));
}
let cur = player.getCurrentTime();
- if(Math.abs(clamped_target - cur) > 0.5)
+ if(Math.abs(clamped_target - cur) > 0.8)
player.seekTo(clamped_target, true);
if(should_play && (loop || clamped_target < duration - 0.05))
@@ -866,14 +866,14 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
const bool is_owner = (ob->video_watch_party_owner_user_id == gui_client->logged_in_user_id.value());
- if(ob->video_watch_party_active && !is_owner && !joined_watch_party)
- setYouTubeWatchPartyButton(html_view_handle, "Join watch party", 1);
- else
- setYouTubeWatchPartyButton(html_view_handle, "Start watch party", ob->video_watch_party_active ? 0 : 1);
+ if(ob->video_watch_party_active && !is_owner)
+ joined_watch_party = true; // Auto-join non-host viewers when a watch party is active.
+
+ setYouTubeWatchPartyButton(html_view_handle, "Start watch party", ob->video_watch_party_active ? 0 : 1);
if(ob->video_watch_party_active)
{
- const bool lock_controls = (!is_owner && joined_watch_party);
- setYouTubeWatchPartyOverlay(html_view_handle, is_owner ? "Watch party: You are host" : (joined_watch_party ? "Watch party: Synced to host" : "Watch party available"), 1, lock_controls ? 1 : 0);
+ const bool lock_controls = !is_owner;
+ setYouTubeWatchPartyOverlay(html_view_handle, is_owner ? "Watch party: You are host" : "Watch party: Synced", 1, lock_controls ? 1 : 0);
}
else
{
@@ -889,17 +889,11 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
joined_watch_party = true;
gui_client->requestVideoWatchPartyState(ob->uid);
}
- else if(action == 2) // Join watch party
- {
- joined_watch_party = true;
- gui_client->requestVideoWatchPartyState(ob->uid);
- }
-
if(ob->video_watch_party_active && gui_client->world_state.nonNull())
{
if(is_owner)
{
- if(anim_time - last_watch_party_sync_check_time > 0.25)
+ if(anim_time - last_watch_party_sync_check_time > 0.5)
{
last_watch_party_sync_check_time = anim_time;
const double start_video_time = getYouTubeCurrentTime(html_view_handle);
@@ -907,7 +901,7 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
gui_client->startVideoWatchParty(ob->uid, start_video_time, is_playing);
}
}
- else if(joined_watch_party && anim_time - last_watch_party_sync_check_time > 0.2)
+ else if(anim_time - last_watch_party_sync_check_time > 0.35)
{
last_watch_party_sync_check_time = anim_time;
@@ -1018,13 +1012,11 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
"btn.style.display='none';btn.style.padding='8px 12px';btn.style.border='0';btn.style.borderRadius='6px';"
"btn.style.color='#fff';btn.style.background='rgba(0,0,0,0.7)';btn.style.font='600 13px sans-serif';btn.style.cursor='pointer';"
"btn.onclick=function(){if(!window.player||!window.player.getCurrentTime)return;"
- "var action=(btn.textContent&&btn.textContent.indexOf('Join')>=0)?'join':'start';"
- "if(action==='join')window.substrataWatchPartyJoined=1;"
+ "var action='start';"
"var t=window.player.getCurrentTime();"
"var p=(window.player.getPlayerState&&window.player.getPlayerState()==YT.PlayerState.PLAYING)?1:0;"
"window.location.href='https://localdomain/watchparty?action='+action+'&t='+encodeURIComponent(t.toString())+'&p='+p;};"
"document.body.appendChild(btn);}"
- "if(window.substrataWatchPartyJoined&&text.indexOf('Join')>=0)visible=false;"
"btn.textContent=text;btn.style.display=visible?'block':'none';};"
"window.substrataSetWatchPartyOverlay=function(text,visible,lockControls){"
"var ov=document.getElementById('watchPartyOverlay');"
@@ -1042,6 +1034,7 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
"blocker.style.display=effectiveLock?'block':'none';};"
"window.substrataSetWatchPartyOwnerActive=function(isOwner,isActive){"
"window.substrataWatchPartyIsOwner=isOwner?1:0;window.substrataWatchPartyActive=isActive?1:0;"
+ "if(isActive&&!isOwner)window.substrataWatchPartyJoined=1;"
"if(!isActive)window.substrataWatchPartyJoined=0;};"
"window.substrataApplyWatchPartyTargetTime=function(targetTime,forcePlay,loopMode){"
"if(!window.substrataWatchPartyIsOwner&&!window.substrataWatchPartyJoined)return;"
@@ -1051,7 +1044,7 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
"if(loopMode){target=((target%duration)+duration)%duration;}else{if(target<0.0)target=0.0;if(target>duration)target=duration;}"
"var cur=window.player.getCurrentTime();var delta=target-cur;"
"if(loopMode){if(delta>duration*0.5)delta-=duration;if(delta<-duration*0.5)delta+=duration;}"
- "if(Math.abs(delta)>0.25)window.player.seekTo(target,true);"
+ "if(Math.abs(delta)>0.8)window.player.seekTo(target,true);"
"if(forcePlay){if(window.player.getPlayerState&&window.player.getPlayerState()!=YT.PlayerState.PLAYING)window.player.playVideo();}"
"else{if(window.player.pauseVideo)window.player.pauseVideo();}};"
"if(!window.substrataWatchPartyHeartbeatTimer){window.substrataWatchPartyLastSentT=-1;window.substrataWatchPartyLastSentP=-1;window.substrataWatchPartyHeartbeatTimer=setInterval(function(){"
@@ -1060,25 +1053,25 @@ void BrowserVidPlayer::process(GUIClient* gui_client, OpenGLEngine* opengl_engin
"var p=(window.player.getPlayerState&&window.player.getPlayerState()==YT.PlayerState.PLAYING)?1:0;"
"var shouldSend=(p!=window.substrataWatchPartyLastSentP)||((p==1)&&Math.abs(t-window.substrataWatchPartyLastSentT)>1.0);"
"if(shouldSend){window.substrataWatchPartyLastSentT=t;window.substrataWatchPartyLastSentP=p;window.location.href='https://localdomain/watchparty?action=update&t='+encodeURIComponent(t.toString())+'&p='+p;}"
- "},1000);}"
+ "},1500);}"
"}");
const bool is_owner = (ob->video_watch_party_owner_user_id == gui_client->logged_in_user_id.value());
- const bool show_button = !ob->video_watch_party_active || !is_owner;
- const std::string button_text = ob->video_watch_party_active ? "Join watch party" : "Start watch party";
+ const bool show_button = !ob->video_watch_party_active;
+ const std::string button_text = "Start watch party";
browser->executeJavaScript("if(window.substrataSetWatchPartyButton){window.substrataSetWatchPartyButton('" + button_text + "', " + toString(show_button ? 1 : 0) + ");}");
browser->executeJavaScript("if(window.substrataSetWatchPartyOwnerActive){window.substrataSetWatchPartyOwnerActive(" + toString(is_owner ? 1 : 0) + ", " + toString(ob->video_watch_party_active ? 1 : 0) + ");}");
if(ob->video_watch_party_active)
{
const bool lock_controls = !is_owner;
- browser->executeJavaScript("if(window.substrataSetWatchPartyOverlay){window.substrataSetWatchPartyOverlay('" + std::string(is_owner ? "Watch party: You are host" : "Watch party available") + "', 1, " + toString(lock_controls ? 1 : 0) + ");}");
+ browser->executeJavaScript("if(window.substrataSetWatchPartyOverlay){window.substrataSetWatchPartyOverlay('" + std::string(is_owner ? "Watch party: You are host" : "Watch party: Synced") + "', 1, " + toString(lock_controls ? 1 : 0) + ");}");
}
else
browser->executeJavaScript("if(window.substrataSetWatchPartyOverlay){window.substrataSetWatchPartyOverlay('', 0, 0);}");
if(ob->video_watch_party_active && gui_client->world_state.nonNull() && !is_owner)
{
- if(anim_time - last_watch_party_sync_check_time > 0.2)
+ if(anim_time - last_watch_party_sync_check_time > 0.35)
{
last_watch_party_sync_check_time = anim_time;
From 4c60b06acd675a7231ffafcbdd04517c0270a9a2 Mon Sep 17 00:00:00 2001
From: Micah Spaier <85509021+JerryBerry12@users.noreply.github.com>
Date: Sun, 22 Mar 2026 22:30:40 -0400
Subject: [PATCH 10/11] Revert "Copy this from my master so i can build from
the workflow, will remove before opening pr"
This reverts commit e32a3373eae6dacfa082fe7a4dd00dfe7f99b9eb.
---
scripts/copy_files_to_output.rb | 148 ++++++++------------------------
1 file changed, 34 insertions(+), 114 deletions(-)
diff --git a/scripts/copy_files_to_output.rb b/scripts/copy_files_to_output.rb
index 111df31f7..0cfd56196 100644
--- a/scripts/copy_files_to_output.rb
+++ b/scripts/copy_files_to_output.rb
@@ -9,17 +9,13 @@
$copy_cef = true
-$copy_bugsplat = false
-$only_config = nil
+$copy_bugsplat = true
def printUsage()
puts "Usage: copy_files_to_output.rb [arguments]"
puts ""
puts "\t--no_cef, \t\tSkip copying CEF files."
- puts "\t--config=\tOnly process the specified build configuration (Windows only)."
- puts ""
- puts "\t"
puts ""
puts "\t--help, -h\t\tShows this help."
puts ""
@@ -32,21 +28,6 @@ def printUsage()
$copy_cef = false
elsif opt[0] == "--no_bugsplat"
$copy_bugsplat = false
- elsif opt[0].start_with?("--config")
- # Accept --config=VALUE or --config VALUE (ArgumentParser may provide value in opt[1])
- val = nil
- if opt[0].include?("=")
- val = opt[0].split("=",2)[1]
- elsif opt.length > 1 && opt[1]
- val = opt[1]
- end
- valid = ["Debug", "RelWithDebInfo", "Release"]
- if val && valid.include?(val)
- $only_config = val
- else
- puts "Invalid or missing value for --config. Expected one of: #{valid.join(', ')}"
- exit 1
- end
elsif opt[0] == "--help" || opt[0] == "-h"
printUsage()
exit 0
@@ -59,24 +40,37 @@ def printUsage()
def copy_files(vs_version, substrata_repos_dir, glare_core_repos_dir)
- # Process configured build configurations. If $only_config is set (via --config),
- # only that configuration will be processed. This is intended for Windows use.
- configs = ["Debug", "RelWithDebInfo", "Release"]
- configs.each do |cfg|
- next if $only_config && cfg != $only_config
+ begin
+ output_dir = getCmakeBuildDir(vs_version, "Debug")
+
+ copyCyberspaceResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
+ copyCyberspaceServerResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
+ copyCEFRedistWindows(output_dir, true) if $copy_cef
+ copyBugSplatRedist(output_dir) if $copy_bugsplat
+ copyQtRedistWindows(vs_version, output_dir, true)
+ copySDLRedistWindows(vs_version, output_dir, true)
+ end
- output_dir = getCmakeBuildDir(vs_version, cfg)
+ begin
+ output_dir = getCmakeBuildDir(vs_version, "RelWithDebInfo")
copyCyberspaceResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
- # Preserve previous behavior where Debug passed an extra 'true' flag.
- if cfg == "Debug"
- copyCEFRedistWindows(output_dir, true) if $copy_cef
- else
- copyCEFRedistWindows(output_dir) if $copy_cef
- end
+ copyCyberspaceServerResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
+ copyCEFRedistWindows(output_dir) if $copy_cef
copyBugSplatRedist(output_dir) if $copy_bugsplat
- copyQtRedistWindows(vs_version, output_dir, cfg == "Debug")
- copySDLRedistWindows(vs_version, output_dir, cfg == "Debug")
+ copyQtRedistWindows(vs_version, output_dir, false)
+ copySDLRedistWindows(vs_version, output_dir, false)
+ end
+
+ begin
+ output_dir = getCmakeBuildDir(vs_version, "Release")
+
+ copyCyberspaceResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
+ copyCyberspaceServerResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
+ copyCEFRedistWindows(output_dir) if $copy_cef
+ copyBugSplatRedist(output_dir) if $copy_bugsplat
+ copyQtRedistWindows(vs_version, output_dir, false)
+ copySDLRedistWindows(vs_version, output_dir, false)
end
end
@@ -88,87 +82,13 @@ def copy_files(vs_version, substrata_repos_dir, glare_core_repos_dir)
if OS.windows?
copy_files(2022, substrata_repos_dir, glare_core_repos_dir)
elsif OS.mac?
- #
- # For mac we need to populate the actual .app bundle that contains the built binary.
- # Previously the script always used the Debug build dir which resulted in populating
- # the skeleton app (e.g. test_builds) instead of the real Release app when BUILD_CONFIG=Release.
- #
- # Strategy:
- # 1) Prefer CYBERSPACE_OUTPUT/gui_client.app if it exists and contains the binary
- # 2) Prefer the config requested by the environment variable BUILD_CONFIG (if set)
- # 3) Search substrata_output and substrata_build for gui_client.app that actually contains the binary
- # 4) Copy resources/CEF into the first app bundle found
- # 5) As a last resort fall back to the old Debug location to preserve backward compatibility
- #
begin
- # 1) Quick check: CYBERSPACE_OUTPUT (CI sets this)
- cs_out = ENV['CYBERSPACE_OUTPUT']
- if cs_out && !cs_out.empty?
- app_candidate = File.join(cs_out, "gui_client.app")
- bin_path = File.join(app_candidate, "Contents", "MacOS", "gui_client")
- if File.exist?(bin_path)
- puts "copy_files_to_output.rb: detected built app in CYBERSPACE_OUTPUT: #{app_candidate}"
- appdir = app_candidate
- output_dir = File.join(appdir, "Contents", "Resources")
- copyCyberspaceResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
- copyCEFRedistMac(File.dirname(appdir), appdir) if $copy_cef
- # done
- exit 0
- else
- puts "copy_files_to_output.rb: CYBERSPACE_OUTPUT set to #{cs_out} but no binary found at #{bin_path}"
- end
- else
- puts "copy_files_to_output.rb: CYBERSPACE_OUTPUT not set or empty; will search common build locations"
- end
-
- # 2) & 3) Search per-config and common locations
- preferred = ENV['BUILD_CONFIG'] ? ENV['BUILD_CONFIG'] : nil
- configs = []
- configs << preferred if preferred
- # common configs
- configs += ['Release', 'RelWithDebInfo', 'Debug']
- # uniq preserve order
- configs = configs.compact.uniq
-
- found_app = nil
- configs.each do |cfg|
- build_dir = getCmakeBuildDir(0, cfg)
- # There are a few places the .app may be placed; check common ones
- candidates = [
- File.join(build_dir, "gui_client.app"),
- File.join(build_dir, "gui_client.app/Contents/MacOS/../"), # legacy pattern
- File.join(File.dirname(build_dir), "substrata_output", "gui_client.app"),
- File.join(File.dirname(build_dir), "substrata_output", "test_builds", "gui_client.app"),
- File.join(File.dirname(build_dir), "substrata_output")
- ]
- candidates.each do |cand|
- # normalize and check for real binary presence
- next if cand.nil? || cand.empty?
- cand_dir = cand.gsub(/\/+/, '/')
- bin_path = File.join(cand_dir, "Contents", "MacOS", "gui_client")
- if File.exist?(bin_path)
- found_app = cand_dir
- break
- end
- end
- break if found_app
- end
-
- if found_app
- appdir = found_app
- output_dir = File.join(appdir, "Contents", "Resources")
- puts "copy_files_to_output.rb: populating app at #{appdir} (detected binary present)"
- copyCyberspaceResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
- copyCEFRedistMac(File.dirname(appdir), appdir) if $copy_cef
- else
- # Last-resort fallback to previous Debug behavior to avoid surprising regressions.
- puts "copy_files_to_output.rb: no built gui_client.app with binary found for configs #{configs.inspect}; falling back to Debug build dir."
- build_dir = getCmakeBuildDir(0, "Debug")
- appdir = File.join(build_dir, "gui_client.app")
- output_dir = File.join(appdir, "Contents", "MacOS", "..", "Resources")
- copyCyberspaceResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
- copyCEFRedistMac(build_dir, appdir) if $copy_cef
- end
+ build_dir = getCmakeBuildDir(0, "Debug")
+ appdir = build_dir + "/gui_client.app"
+ output_dir = build_dir + "/gui_client.app/Contents/MacOS/../Resources"
+
+ copyCyberspaceResources(substrata_repos_dir, glare_core_repos_dir, output_dir)
+ copyCEFRedistMac(build_dir, appdir) if $copy_cef
end
begin
From c89b550c47aed837df3670d41b8b776e8c86e18e Mon Sep 17 00:00:00 2001
From: Micah Spaier <85509021+JerryBerry12@users.noreply.github.com>
Date: Sun, 22 Mar 2026 22:30:46 -0400
Subject: [PATCH 11/11] Revert "Copy this from my master so i can build from
the workflow, will remove before opening pr"
This reverts commit d96584b1d933223c632af85cb862164ae414caab.
---
scripts/dist_utils.rb | 36 +++++-------------------------------
1 file changed, 5 insertions(+), 31 deletions(-)
diff --git a/scripts/dist_utils.rb b/scripts/dist_utils.rb
index 7f19e832d..9528f1c41 100644
--- a/scripts/dist_utils.rb
+++ b/scripts/dist_utils.rb
@@ -99,36 +99,10 @@ def copySDLRedistWindows(vs_version, target_dir, copy_debug)
# Get SDL path.
glare_core_libs_dir = getAndCheckEnvVar('GLARE_CORE_LIBS')
- sdl_build_dir = "#{glare_core_libs_dir}/SDL/sdl_2.30.9_build"
- sdl_install_dir = "#{glare_core_libs_dir}/SDL/sdl_2.30.9_install"
-
- # Prefer the build dir (used in CI when building from source), but fall back
- # to an install dir (used when deps are supplied prebuilt from the store).
- if copy_debug
- src = File.join(sdl_build_dir, "Debug", "SDL2d.dll")
- if !File.exist?(src)
- # fallback
- alt = File.join(sdl_install_dir, "Debug", "SDL2d.dll")
- src = alt if File.exist?(alt)
- end
- if File.exist?(src)
- FileUtils.cp(src, target_dir, :verbose => true)
- else
- STDERR.puts "Warning: SDL debug DLL not found at #{src} (skipping)"
- end
- else
- src = File.join(sdl_build_dir, "Release", "SDL2.dll")
- if !File.exist?(src)
- # fallback
- alt = File.join(sdl_install_dir, "Release", "SDL2.dll")
- src = alt if File.exist?(alt)
- end
- if File.exist?(src)
- FileUtils.cp(src, target_dir, :verbose => true)
- else
- STDERR.puts "Warning: SDL release DLL not found at #{src} (skipping)"
- end
- end
+ sdl_dir = "#{glare_core_libs_dir}/SDL/sdl_2.30.9_build"
+
+ FileUtils.cp("#{sdl_dir}/Debug/SDL2d.dll", target_dir, :verbose => true) if copy_debug
+ FileUtils.cp("#{sdl_dir}/Release/SDL2.dll", target_dir, :verbose => true) if !copy_debug
end
@@ -279,4 +253,4 @@ def copyCyberspaceServerResources(substrata_repos_dir, glare_core_repos_dir, dis
# Copy licence.txt
FileUtils.cp_r(substrata_repos_dir + "/docs/licence.txt", "#{dist_dir}/", :verbose => true)
-end
\ No newline at end of file
+end