module.exports = class WatchParty { getName() { return "WatchParty"; } getImage() { return "https://raw.githubusercontent.com/MateusAquino/WatchParty/main/logo.png"; } getDescription() { return "Start a Stremio session with friends: watch party, chat (soon) and share controls. No addon sharing required."; } getVersion() { return "1.0.1"; } getAuthor() { return "MateusAquino"; } getShareURL() { return "https://github.com/MateusAquino/WatchParty"; } getUpdateURL() { return "https://raw.githubusercontent.com/MateusAquino/WatchParty/main/WatchParty.plugin.js"; } onBoot() {} onReady() {} onLoad() { const windowControls = document.getElementById("window-controls"); const WatchPartyControl = document.createElement("li"); WatchPartyControl.id = "wp-control"; WatchPartyControl.innerHTML = this.control(); const WatchPartyPopup = document.createElement("div"); WatchPartyPopup.id = "wp-popup"; WatchPartyPopup.innerHTML = this.popup(); WatchPartyPopup.classList.add("wp-noparty"); WatchPartyPopup.classList.add("wp-noparty"); WatchPartyControl.onclick = () => this.openUI(); windowControls.insertBefore(WatchPartyControl, windowControls.firstChild); windowControls.insertBefore( WatchPartyPopup, windowControls.firstChild.nextSibling ); window.WatchParty = {}; window.WatchParty.code = () => WatchParty.client?.party?.code; window.WatchParty.create = () => this.btnCreate(this.element); window.WatchParty.join = () => this.btnJoin(this.element); window.WatchParty.confirm = () => this.btnConfirm(this.element); window.WatchParty.toggle = (userId) => WatchParty.client.send(`toggle:${userId}`); window.WatchParty.broadcast = this.broadcastCommand; window.WatchParty.execute = this.execCommand; window.WatchParty.inject = this.inject; window.WatchParty.leave = () => WatchParty.client?.terminate?.() || WatchParty.client?.close?.(); window.WatchParty.failedServers = []; const servers = this.getServers(); for (const server of Object.values(servers)) { const statusEndpoint = server .replace("ws://", "http://") .replace("wss://", "https://"); const xhr = new XMLHttpRequest(); xhr.open("GET", statusEndpoint, true); xhr.onload = () => { if (xhr.status === 200) console.log("[WatchParty] Server available: ", server); }; xhr.send(); } this.inject(); } onEnable() { this.onLoad(); } onDisable() { WatchParty.client?.terminate?.() || WatchParty.client?.close?.(); this.element("control").remove(); window.WatchParty = undefined; } getServers() { return { L: "ws://localhost:3000/", R: "wss://watchparty-kyiy.onrender.com", G: "wss://dramatic-hazel-epoch.glitch.me", // A: "wss://watch-party.adaptable.app", }; } pickRandom(partyServer) { const servers = this.getServers(); const availableServers = Object.keys(servers).filter( (server) => server.startsWith(partyServer) && !window.WatchParty.failedServers.includes(servers[server]) ); if (availableServers.length > 0) { const randomServer = availableServers[Math.floor(Math.random() * availableServers.length)]; return servers[randomServer]; } else { return false; } } connect(protocol, partyServer = "") { if (WatchParty.client) WatchParty.client.terminate?.() || WatchParty.client.close?.(); const server = this.pickRandom(partyServer); if (!server) { document.getElementById("wp-popup").classList.remove("wp-loading"); document.getElementById("wp-popup").classList.add("wp-noparty"); return BetterStremio.Toasts.error( "Failed to create/join party.", "Couldn't find any servers available! Try again in a minute." ); } WatchParty.client = new WebSocket(server, protocol); WatchParty.client.heartbeat = () => { const client = this; clearTimeout(this.pingTimeout); this.pingTimeout = setTimeout(() => { client.terminate?.() || client.close?.(); console.error("[WatchParty] Connection timeout!"); BetterStremio.Toasts.error( "Disconnected from party!", "You have been disconnected due to timeout." ); }, 30000 + 4000); }; const retry = () => this.connect(protocol, partyServer); WatchParty.client.onerror = () => { window.WatchParty.failedServers.push(server); retry(); setTimeout(() => { const index = window.WatchParty.failedServers.indexOf(server); if (index > -1) window.WatchParty.failedServers.splice(index, 1); }, 1 * 60 * 1000); }; WatchParty.client.onmessage = this.handleMessage; WatchParty.client.onopen = WatchParty.client.heartbeat; window.WatchParty.warnOnClose = false; const warnTimeout = setTimeout(() => { window.WatchParty.warnOnClose = true; }, 5000); WatchParty.client.onclose = () => { clearTimeout(warnTimeout); if (window.WatchParty.warnOnClose) BetterStremio.Toasts.warning("Disconnected!", "You've left the party."); document.getElementById("wp-popup").classList.remove("wp-loading"); document.getElementById("wp-popup").classList.add("wp-noparty"); console.log("[WatchParty] Connection closed."); clearTimeout(this.pingTimeout); }; } openUI() { this.element("popup").classList.toggle("show"); } element(id) { return document.getElementById(`wp-${id}`); } btnCreate(element) { delete this.isJoining; element("create-btn").classList.add("selected"); element("join-btn").classList.remove("selected"); element("create").classList.remove("hidden"); element("join").classList.add("hidden"); } btnJoin(element) { this.isJoining = true; element("create-btn").classList.remove("selected"); element("join-btn").classList.add("selected"); element("create").classList.add("hidden"); element("join").classList.remove("hidden"); } btnConfirm(element) { element("popup").classList.add("wp-loading"); if (!this.isJoining) { const username = encodeURIComponent(element("create-user").value); const partyName = encodeURIComponent(element("create-name").value); const partyPass = encodeURIComponent(element("create-pass").value); const joinAsHost = element("create-joinashost").checked; const protocol = `c#1#${username}#${partyPass}#${partyName}#${ joinAsHost ? "1" : "0" }`; this.connect(protocol); } else { const username = encodeURIComponent(element("join-user").value); const partyCode = encodeURIComponent( element("join-code").value ).toUpperCase(); const partyPass = encodeURIComponent(element("join-pass").value); const protocol = `j#1#${username}#${partyCode}#${partyPass}`; this.connect(protocol, partyCode.substring(0, 1)); } } handleMessage(message) { document.getElementById("wp-popup").classList.remove("wp-loading"); const client = message.currentTarget; if (message.data === "ping") { client.send("pong"); client.heartbeat(); } else if (message.data === "badroom") { document.getElementById("wp-join-pass").value = ""; BetterStremio.Toasts.error( "Failed to join party!", "The combination code/password does not exists." ); } else if (message.data === "upgrade") BetterStremio.Toasts.error( "WatchParty version not supported!", "Please upgrade your plugin version to use " ); else if (message.data.startsWith("party:")) { const newParty = JSON.parse(message.data.substring(6)); if (client.party && newParty) { const oldMembers = client.party.members; const newMembers = newParty.members; if (oldMembers.length < newMembers.length) { const joinedMember = newMembers.find( (member) => !oldMembers.map((el) => el.userId).includes(member.userId) ); BetterStremio.Toasts.info( "Party Update", `${joinedMember.userName} has joined the party` ); } else if (oldMembers.length > newMembers.length) { const leftMember = oldMembers.find( (member) => !newMembers.map((el) => el.userId).includes(member.userId) ); BetterStremio.Toasts.info( "Party Update", `${leftMember.userName} has left the party` ); } else { for (let i = 0; i < newMembers.length; i++) { if (oldMembers[i].isHost !== newMembers[i].isHost) { const member = newMembers[i]; const action = member.isHost ? "promoted to" : "demoted from"; BetterStremio.Toasts.info( "Party Update", `${member.userName} was ${action} host` ); } } } } client.party = newParty; document.getElementById("wp-popup").classList.remove("wp-noparty"); document.getElementById("wp-partyname").innerText = newParty.name; document.getElementById("wp-partycode").innerText = newParty.code; document.getElementById("wp-partymembers").innerHTML = newParty.members .map( (member) => `