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