/* This file is part of jellything (https://codeberg.org/metamuffin/jellything) which is licensed under the GNU Affero General Public License (version 3); see /COPYING. Copyright (C) 2025 metamuffin */ /// import { OVar, e } from "../jshelper/mod.ts"; import { Logger } from "../jshelper/src/log.ts"; import { Player } from "./player.ts" export function playersync_controls(sync_state: OVar, player: Player) { let channel_name: HTMLInputElement; let channel_name_copy: HTMLInputElement; return e("div", { class: ["jsp-controlgroup", "jsp-playersync-controls"] }, e("h3", "Playersync"), sync_state.map(sync => sync ? e("div", e("span", "Sync enabled."), e("button", "Disable", { onclick: () => { sync_state.value?.destroy(); sync_state.value = undefined } }), e("p", "Session ID: ", channel_name_copy = e("input", { type: "text", disabled: true, value: sync.name }), e("button", "content_paste_go", { class: "icon", onclick: () => { player.logger?.log("Session ID copied to clipboard.") navigator.clipboard.writeText(channel_name_copy.value) } }) ), e("h4", "Users"), sync.users.map(users => e("ul", ...[...users.keys()].map(u => e("li", u))) ) ) : e("div", channel_name = e("input", { type: "text", placeholder: "someroom:example.org" }), e("button", "Join", { onclick: () => { if (!channel_name.value.length) return sync_state.value?.destroy() sync_state.value = new Playersync(player, player.logger!, channel_name.value) } }), e("br"), e("button", "Create new session", { onclick: () => { sync_state.value?.destroy() sync_state.value = new Playersync(player, player.logger!) } })) ) ) } function get_username() { return document.querySelector("nav .account .username")?.textContent ?? "Unknown User" } interface Packet { time?: number, playing?: boolean, join?: string, leave?: string, } export class Playersync { private ws: WebSocket private on_destroy: (() => void)[] = [] public name: string public users = new OVar(new Map()) private cancel_pers: undefined | (() => void) set_pers(s?: string) { if (this.cancel_pers) this.cancel_pers(), this.cancel_pers = undefined if (s) this.cancel_pers = this.logger?.log_persistent(s) } constructor(private player: Player, private logger: Logger, private channel_name?: string) { this.set_pers("Playersync enabling...") channel_name ??= Math.random().toString(16).padEnd(5, "0").substring(2).substring(0, 6) let [localpart, remotepart, port] = channel_name.split(":") if (!remotepart?.length) remotepart = globalThis.location.host if (port) remotepart += ":" + port this.name = localpart + ":" + remotepart this.ws = new WebSocket(`${globalThis.location.protocol.endsWith("s:") ? "wss" : "ws"}://${remotepart}/playersync/${encodeURIComponent(localpart)}`) this.on_destroy.push(() => this.ws.close()) this.ws.onopen = () => { this.set_pers() this.logger.log(`Playersync connected.`) this.send({ join: get_username() }) } this.ws.onerror = () => { this.set_pers(`Playersync websocket error.`) } this.ws.onclose = () => { this.set_pers(`Playersync websocket closed.`) } let last_time = 0; this.ws.onmessage = ev => { const packet: Packet = JSON.parse(ev.data) console.log("playersync recv", packet); if (packet.time !== undefined) { this.player.seek(packet.time) last_time = packet.time } if (packet.playing === true) this.player.play() if (packet.playing === false) this.player.pause() if (packet.join) { this.logger.log(`${packet.join} joined.`) this.users.value.set(packet.join, null) this.users.change() } if (packet.leave) { this.logger.log(`${packet.leave} left.`) this.users.value.delete(packet.leave) this.users.change() } } let cb: () => void const send_time = () => { const time = this.player.video.currentTime if (Math.abs(last_time - time) < 0.01) return this.send({ time: this.player.video.currentTime }) } player.video.addEventListener("play", cb = () => { send_time() this.send({ playing: true }) }) this.on_destroy.push(() => player.video.removeEventListener("play", cb)) player.video.addEventListener("pause", cb = () => { this.send({ playing: false }) send_time() }) this.on_destroy.push(() => player.video.removeEventListener("pause", cb)) player.video.addEventListener("seeking", cb = () => { send_time() }) this.on_destroy.push(() => player.video.removeEventListener("seeking", cb)) } destroy() { this.set_pers() this.logger.log("Playersync disabled.") this.on_destroy.forEach(f => f()) } send(p: Packet) { console.log("playersync send", p); this.ws.send(JSON.stringify(p)) } }