From 67d19eb324dbd63c1c30d18fd99c70e574adff83 Mon Sep 17 00:00:00 2001
From: ThaUnknown <6506529+ThaUnknown@users.noreply.github.com>
Date: Sun, 25 Aug 2024 16:51:16 +0200
Subject: [PATCH] feat: w2g chat
---
common/modules/sections.js | 3 +-
common/views/Player/MediaHandler.svelte | 2 +-
common/views/Player/Player.svelte | 4 +-
common/views/WatchTogether/Lobby.svelte | 103 ++++++--
common/views/WatchTogether/Message.svelte | 47 ++++
common/views/WatchTogether/User.svelte | 20 ++
.../views/WatchTogether/WatchTogether.svelte | 166 +++----------
common/views/WatchTogether/events.js | 16 ++
common/views/WatchTogether/w2g.js | 233 ++++++++++++++++++
9 files changed, 434 insertions(+), 160 deletions(-)
create mode 100644 common/views/WatchTogether/Message.svelte
create mode 100644 common/views/WatchTogether/User.svelte
create mode 100644 common/views/WatchTogether/events.js
create mode 100644 common/views/WatchTogether/w2g.js
diff --git a/common/modules/sections.js b/common/modules/sections.js
index eaecb26..5c1ff1f 100644
--- a/common/modules/sections.js
+++ b/common/modules/sections.js
@@ -190,7 +190,6 @@ function createSections () {
{ title: 'Romance', variables: { sort: 'TRENDING_DESC', genre: 'Romance' } },
{ title: 'Action', variables: { sort: 'TRENDING_DESC', genre: 'Action' } },
{ title: 'Adventure', variables: { sort: 'TRENDING_DESC', genre: 'Adventure' } },
- { title: 'Fantasy', variables: { sort: 'TRENDING_DESC', genre: 'Fantasy' } },
- { title: 'Comedy', variables: { sort: 'TRENDING_DESC', genre: 'Comedy' } }
+ { title: 'Fantasy', variables: { sort: 'TRENDING_DESC', genre: 'Fantasy' } }
]
}
diff --git a/common/views/Player/MediaHandler.svelte b/common/views/Player/MediaHandler.svelte
index e83cf53..740ffc0 100644
--- a/common/views/Player/MediaHandler.svelte
+++ b/common/views/Player/MediaHandler.svelte
@@ -211,7 +211,7 @@
}
function setDiscordRPC (np = nowPlaying.value) {
- const w2g = state.value
+ const w2g = state.value?.code
const details = [np.title, np.episodeTitle].filter(i => i).join(' - ') || undefined
const activity = {
details,
diff --git a/common/views/Player/Player.svelte b/common/views/Player/Player.svelte
index bc1be42..6d1a2e1 100644
--- a/common/views/Player/Player.svelte
+++ b/common/views/Player/Player.svelte
@@ -22,12 +22,12 @@
const emit = createEventDispatcher()
- w2gEmitter.on('playerupdate', ({ detail }) => {
+ w2gEmitter.on('playerupdate', detail => {
currentTime = detail.time
paused = detail.paused
})
- w2gEmitter.on('setindex', ({ detail }) => {
+ w2gEmitter.on('setindex', detail => {
playFile(detail)
})
diff --git a/common/views/WatchTogether/Lobby.svelte b/common/views/WatchTogether/Lobby.svelte
index df0a297..eaf15b6 100644
--- a/common/views/WatchTogether/Lobby.svelte
+++ b/common/views/WatchTogether/Lobby.svelte
@@ -1,19 +1,94 @@
-
-
-
Lobby
-
-
+
+
+
+ {#each groupMessages($messages) as { user, messages, type, date }}
+
+ {/each}
+
+
+
+
+
+
+
+
+ {#if peers}
+
+ {Object.values($peers).length} Member(s)
+
+ {#each Object.values($peers) as user}
+
+ {/each}
+ {/if}
+
- {#each Object.values(peers) as peer}
+
-
-
diff --git a/common/views/WatchTogether/Message.svelte b/common/views/WatchTogether/Message.svelte
new file mode 100644
index 0000000..b49f8e7
--- /dev/null
+++ b/common/views/WatchTogether/Message.svelte
@@ -0,0 +1,47 @@
+
+
+
+

+
+
+
+ {user.name || 'Anonymous'}
+
+
+ {time.toLocaleTimeString()}
+
+
+ {#each messages as message}
+
+ {message}
+
+ {/each}
+
+
+
+
diff --git a/common/views/WatchTogether/User.svelte b/common/views/WatchTogether/User.svelte
new file mode 100644
index 0000000..21a6ca1
--- /dev/null
+++ b/common/views/WatchTogether/User.svelte
@@ -0,0 +1,20 @@
+
+
+
+

+
+ {user.name || 'Anonymous'}
+
+ {#if user.name}
+
IPC.emit('open', 'https://anilist.co/user/' + user.name)}>
+
+
+ {/if}
+
diff --git a/common/views/WatchTogether/WatchTogether.svelte b/common/views/WatchTogether/WatchTogether.svelte
index e1cc9fc..3b4668b 100644
--- a/common/views/WatchTogether/WatchTogether.svelte
+++ b/common/views/WatchTogether/WatchTogether.svelte
@@ -1,164 +1,54 @@
-
-
Watch Together
+
{#if !$state}
-
+
Watch Together
+
@@ -196,7 +86,7 @@
{:else}
-
+
{/if}
diff --git a/common/views/WatchTogether/events.js b/common/views/WatchTogether/events.js
new file mode 100644
index 0000000..ba51188
--- /dev/null
+++ b/common/views/WatchTogether/events.js
@@ -0,0 +1,16 @@
+export default class Event {
+ payload = null
+ type = ''
+ constructor (type, payload) {
+ this.type = type
+ this.payload = payload
+ }
+}
+
+export const EventTypes = {
+ SessionInitEvent: 'init',
+ MagnetLinkEvent: 'magnet',
+ MediaIndexEvent: 'index',
+ PlayerStateEvent: 'player',
+ MessageEvent: 'message'
+}
diff --git a/common/views/WatchTogether/w2g.js b/common/views/WatchTogether/w2g.js
new file mode 100644
index 0000000..65e474b
--- /dev/null
+++ b/common/views/WatchTogether/w2g.js
@@ -0,0 +1,233 @@
+import { EventEmitter } from 'events'
+
+import P2PT from 'p2pt'
+
+import Event, { EventTypes } from './events.js'
+import { anilistClient } from '@/modules/anilist.js'
+import { add } from '@/modules/torrent.js'
+import { generateRandomHexCode } from '@/modules/util.js'
+import { writable } from 'simple-store-svelte'
+import Debug from 'debug'
+
+const debug = Debug('ui:w2g')
+
+/**
+ * @typedef {Record
}>} PeerList
+ */
+
+export class W2GClient extends EventEmitter {
+ static #announce = [
+ atob('d3NzOi8vdHJhY2tlci5vcGVud2VidG9ycmVudC5jb20='),
+ atob('d3NzOi8vdHJhY2tlci53ZWJ0b3JyZW50LmRldg=='),
+ atob('d3NzOi8vdHJhY2tlci5maWxlcy5mbTo3MDczL2Fubm91bmNl'),
+ atob('d3NzOi8vdHJhY2tlci5idG9ycmVudC54eXov')
+ ]
+
+ player = {
+ paused: true,
+ time: 0
+ }
+
+ index = 0
+ /** @type {{maget: 'string', hash: 'string'} | null} */
+ magnet = null
+ isHost = false
+ #p2pt
+ code
+ /** @type {import('simple-store-svelte').Writable<{message: string, user: import('@/modules/al.d.ts').Viewer | {id: string }, type: 'incoming' | 'outgoing', date: Date}[]>} */
+ messages = writable([])
+
+ self = anilistClient.userID?.viewer.data.Viewer || { id: generateRandomHexCode(16) }
+ /** @type {import('simple-store-svelte').Writable} */
+ peers = writable({ [this.self.id]: { user: this.self } })
+
+ get inviteLink () {
+ return `https://miru.watch/w2g/${this.code}`
+ }
+
+ /**
+ * Should be called when media index changed locally
+ * @param {number} index
+ */
+ localMediaIndexChanged (index) {
+ this.index = index
+
+ this.mediaIndexChanged(index)
+ }
+
+ /**
+ * Should be called when player state changed locally
+ * @param {import('./events.js').default} state
+ */
+ localPlayerStateChanged ({ payload }) {
+ debug(`localPlayerStateChanged: ${JSON.stringify(payload)}`)
+ this.player.payload.paused = payload.paused
+ this.player.payload.time = payload.time
+
+ this.playerStateChanged(this.player)
+ }
+
+ /**
+ * @param {string} code lobby code
+ */
+ constructor (code) {
+ super()
+ this.isHost = !code
+
+ this.code = code ?? generateRandomHexCode(16)
+
+ debug(`W2GClient: ${this.code}, ${this.isHost}`)
+
+ this.#p2pt = new P2PT(W2GClient.#announce, this.code)
+
+ this.#wireEvents()
+ this.#p2pt.start()
+ }
+
+ magnetLink (magnet) {
+ debug(`magnetLink: ${this.magnet?.hash} ${magnet.hash}`)
+ if (this.magnet?.hash !== magnet.hash) {
+ this.magnet = magnet
+ this.isHost = true
+ this.#sendToPeers(new Event('magnet', magnet))
+ }
+ }
+
+ /** @param {number} index */
+ mediaIndexChanged (index) {
+ debug(`mediaIndexChanged: ${this.index} ${index}`)
+ if (this.index !== index) {
+ this.index = index
+ this.#sendToPeers(new Event('index', index))
+ }
+ }
+
+ _playerStateChanged (state) {
+ debug(`_playerStateChanged: ${this.player.paused} ${state.paused} ${this.player.time} ${state.time}`)
+ if (this.player.paused !== state.paused || this.player.time !== state.time) {
+ this.player = state
+ return true
+ }
+ }
+
+ playerStateChanged (state) {
+ debug(`playerStateChanged: ${JSON.stringify(state)}`)
+ if (this._playerStateChanged(state)) this.#sendToPeers(new Event('player', state))
+ }
+
+ message (message) {
+ debug(`message: ${message}`)
+ this.messages.update(messages => [...messages, ({
+ message,
+ user: this.self,
+ type: 'outgoing',
+ date: new Date()
+ })])
+ this.#sendToPeers(new Event('message', message))
+ }
+
+ #wireEvents () {
+ this.#p2pt.on('peerconnect', this.#onPeerconnect.bind(this))
+ this.#p2pt.on('msg', this.#onMsg.bind(this))
+ this.#p2pt.on('peerclose', this.#onPeerclose.bind(this))
+ }
+
+ /**
+ * @param {import('p2pt').Peer} peer
+ * @param {import('./events.js').default} event
+ */
+ #sendEvent (peer, event) {
+ debug(`#sendEvent: ${peer.id} ${JSON.stringify(event)}`)
+ this.#p2pt?.send(peer, JSON.stringify(event))
+ }
+
+ /**
+ * Should be called only on 'peerconnect'
+ * @param {import('p2pt').Peer} peer
+ */
+ #sendInitialSessionState (peer) {
+ this.#sendEvent(peer, new Event('magnet', this.magnet))
+ this.#sendEvent(peer, new Event('index', this.index))
+ this.#sendEvent(peer, new Event('player', this.player))
+ }
+
+ async #onPeerconnect (peer) {
+ debug(`#onPeerconnect: ${peer.id}`)
+ this.#sendEvent(peer, new Event('init', this.self))
+
+ if (this.isHost) this.#sendInitialSessionState(peer)
+ }
+
+ /**
+ * @param {import('p2pt').Peer} peer
+ * @param {Event} data
+ */
+ #onMsg (peer, data) {
+ debug(`#onMsg: ${peer.id} ${JSON.stringify(data)}`)
+ data = typeof data === 'string' ? JSON.parse(data) : data
+
+ switch (data.type) {
+ case EventTypes.SessionInitEvent:
+ this.peers.update(peers => {
+ peers[peer.id] = {
+ peer,
+ user: data.payload
+ }
+ return peers
+ })
+ break
+ case EventTypes.MagnetLinkEvent: {
+ const { hash, magnet } = data.payload
+ if (hash !== this.magnet?.hash) {
+ this.isHost = false
+ this.magnet = data.payload
+ add(magnet)
+ }
+
+ break
+ }
+ case EventTypes.MediaIndexEvent: {
+ if (this.index !== data.payload.index) {
+ this.index = data.payload.index
+ this.emit('index', data.payload.index)
+ }
+ break
+ }
+ case EventTypes.PlayerStateEvent: {
+ if (this._playerStateChanged(data.payload)) this.emit('player', data.payload)
+ break
+ }
+ case EventTypes.MessageEvent:{
+ this.messages.update(messages => [...messages, ({ message: data.payload, user: this.peers.value[peer.id].user, type: 'incoming', date: new Date() })])
+ break
+ }
+ default:
+ console.error('Invalid message type', data)
+ }
+ }
+
+ #onPeerclose (peer) {
+ debug(`#onPeerclose: ${peer.id}`)
+ this.peers.update(peers => {
+ delete peers[peer.id]
+ return peers
+ })
+ }
+
+ /** @param {import('./events.js').default} event */
+ #sendToPeers (event) {
+ if (!this.#p2pt) return
+
+ for (const { peer } of Object.values(this.peers)) {
+ if (peer) this.#sendEvent(peer, event)
+ }
+ }
+
+ destroy () {
+ debug('destroy')
+ this.#p2pt.destroy()
+ this.#p2pt = null
+ this.isHost = false
+ this.peers.value = {}
+ }
+}