diff --git a/gui_client/BrowserVidPlayer.cpp b/gui_client/BrowserVidPlayer.cpp index 7f9be2b4d..2ff5f7efa 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 } @@ -110,6 +115,7 @@ static std::string makeEmbedHTMLForVideoURL(const std::string& video_url, int wi const bool loop = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_LOOP); const bool muted = BitUtils::isBitSet(ob->flags, WorldObject::VIDEO_MUTED); + // See https://developers.google.com/youtube/player_parameters const std::string player_params = "{ \n" @@ -122,10 +128,10 @@ 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. + " } \n" " } \n" "}"; @@ -315,7 +321,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 +381,156 @@ 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(); + } } - } - }; - + }; //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(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; + + 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.8) + 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 +745,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 +813,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 +847,78 @@ 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; + } + + 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 = 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; + setYouTubeWatchPartyOverlay(html_view_handle, is_owner ? "Watch party: You are host" : "Watch party: Synced", 1, lock_controls ? 1 : 0); + } + else + { + 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); + 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); + } + if(ob->video_watch_party_active && gui_client->world_state.nonNull()) + { + if(is_owner) + { + 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); + 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.35) + { + 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_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; + + if(loop) + applyYouTubeWatchPartyTargetTime(html_view_handle, target_time, should_play, 1); + else + applyYouTubeWatchPartyTargetTime(html_view_handle, target_time, should_play, 0); + } + } + } + } } else { @@ -741,6 +965,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) { @@ -760,6 +987,106 @@ 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='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);}" + "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';" + "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&&!isOwner)window.substrataWatchPartyJoined=1;" + "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;" + "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){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(){" + "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;" + "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;}" + "},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; + 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") + "', 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.35) + { + 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_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; + + browser->executeJavaScript("if(window.substrataApplyWatchPartyTargetTime){window.substrataApplyWatchPartyTargetTime(" + toString(target_time) + ", " + toString(should_play) + ", " + 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: { @@ -783,6 +1110,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()) @@ -806,6 +1135,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/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..95782fae2 100644 --- a/gui_client/ClientThread.cpp +++ b/gui_client/ClientThread.cpp @@ -906,6 +906,38 @@ 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(); + 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); + 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->video_watch_party_is_playing = is_playing; + 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/EmbeddedBrowser.cpp b/gui_client/EmbeddedBrowser.cpp index eb1a61ace..e5b77c6ec 100644 --- a/gui_client/EmbeddedBrowser.cpp +++ b/gui_client/EmbeddedBrowser.cpp @@ -636,6 +636,54 @@ 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" || 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); + + 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") + { + 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 +1275,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(); diff --git a/gui_client/GUIClient.cpp b/gui_client/GUIClient.cpp index 176b3635c..956338181 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,30 @@ 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, bool is_playing) +{ + 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); + scratch_packet.writeUInt32(is_playing ? 1u : 0u); + 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..22aa43308 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, 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/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..a7996dee7 100644 --- a/server/ServerWorldState.h +++ b/server/ServerWorldState.h @@ -221,6 +221,16 @@ 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), 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); } 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 +258,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 +269,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..2198e5059 100644 --- a/server/WorkerThread.cpp +++ b/server/WorkerThread.cpp @@ -2198,6 +2198,92 @@ void WorkerThread::doRun() } } } + break; + } + case Protocol::VideoWatchPartyStart: + { + const UID object_uid = readUIDFromStream(msg_buffer); + const double requested_start_video_time = msg_buffer.readDouble(); + 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; + + { + 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]; + 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 = 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; + 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); + scratch_packet.writeUInt32(state_to_send.is_playing ? 1u : 0u); + 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); + scratch_packet.writeUInt32(state_to_send.is_playing ? 1u : 0u); + MessageUtils::updatePacketLengthField(scratch_packet); + enqueueDataToSend(scratch_packet); + break; } case Protocol::ObjectPhysicsOwnershipTaken: @@ -2342,6 +2428,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..b45592e4e 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,12 @@ 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; + video_watch_party_is_playing = true; + transmission_time_offset = 0; chunk_batch0_start = chunk_batch0_end = chunk_batch1_start = chunk_batch1_end = 0; @@ -1033,6 +1040,12 @@ 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; + video_watch_party_is_playing = other.video_watch_party_is_playing; + 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..9c6a8926e 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,13 @@ 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; + 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 static const uint32 CONTENT_CHANGED = 4; // Set when script is changed