diff options
Diffstat (limited to 'ui/client-scripts/src/player/sync.ts')
| -rw-r--r-- | ui/client-scripts/src/player/sync.ts | 163 |
1 files changed, 163 insertions, 0 deletions
diff --git a/ui/client-scripts/src/player/sync.ts b/ui/client-scripts/src/player/sync.ts new file mode 100644 index 0000000..5f33a8e --- /dev/null +++ b/ui/client-scripts/src/player/sync.ts @@ -0,0 +1,163 @@ +/* + 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) 2026 metamuffin <metamuffin.org> +*/ +/// <reference lib="dom" /> +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<undefined | Playersync>, 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<string, null>()) + + 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<string>, 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)) + } +} + |