File size: 15,011 Bytes
d092f57
 
 
 
 
 
 
 
 
 
 
 
 
3c9a7c2
d092f57
be73381
d092f57
cd35cdb
 
 
 
 
3c9a7c2
 
 
 
 
d092f57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5877a31
 
3394d66
 
 
d092f57
 
 
 
 
 
 
 
 
5877a31
 
d092f57
 
 
 
 
 
5877a31
d092f57
18510dc
 
 
 
 
 
 
 
 
 
 
d092f57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7f6fc57
d092f57
 
 
 
 
 
7f6fc57
8b825da
 
 
 
d092f57
8b825da
7f6fc57
 
8b825da
7f6fc57
 
8b825da
7f6fc57
d092f57
 
7f6fc57
d092f57
7f6fc57
d092f57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
be73381
 
 
 
 
 
cd35cdb
be73381
 
 
 
 
 
6fc62e6
 
3c9a7c2
6fc62e6
 
 
 
 
 
 
 
 
 
 
be73381
3c9a7c2
d092f57
 
be73381
d092f57
 
 
8ef004b
 
 
 
 
 
 
 
 
 
 
3c9a7c2
8ef004b
 
 
 
470136c
 
 
f2183cc
470136c
 
 
 
 
 
 
 
 
 
 
 
 
d092f57
 
 
 
 
 
 
 
 
 
 
18510dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d092f57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18510dc
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
import * as socketIo from "socket.io"
import { Server } from "socket.io"
import { NextApiRequest, NextApiResponse } from "next"
import { ClientToServerEvents, ServerToClientEvents } from "../../lib/socket"
import {
  decUsers,
  deleteRoom,
  getRoom,
  incUsers,
  roomExists,
  setRoom,
} from "../../lib/cache"
import { createNewRoom, createNewUser, updateLastSync } from "../../lib/room"
import { Playlist, RoomState, UserState, ChatMessage, MediaElement } from "../../lib/types"
import { isUrl } from "../../lib/utils"
import { getDefaultImg, getDefaultSrc } from "../../lib/env"

/**
 * Helper function to create a MediaElement from a URL
 * @param url - The media URL to wrap
 * @returns MediaElement with a single source and no subtitles
 */
const createMediaElement = (url: string): MediaElement => ({
  src: [{ src: url, resolution: "" }],
  sub: [],
})

const ioHandler = (_: NextApiRequest, res: NextApiResponse) => {
  // @ts-ignore
  if (res.socket !== null && "server" in res.socket && !res.socket.server.io) {
    console.log("*First use, starting socket.io")

    const io = new Server<ClientToServerEvents, ServerToClientEvents>(
      // @ts-ignore
      res.socket.server,
      {
        path: "/api/socketio",
      }
    )

    const broadcast = async (room: string | RoomState) => {
      const roomId = typeof room === "string" ? room : room.id

      if (typeof room !== "string") {
        await setRoom(roomId, room)
      } else {
        const d = await getRoom(roomId)
        if (d === null) {
          throw Error("Impossible room state of null for room: " + roomId)
        }
        room = d
      }

      room.serverTime = new Date().getTime()
      io.to(roomId).emit("update", room)
    }

    io.on(
      "connection",
      async (
        socket: socketIo.Socket<ClientToServerEvents, ServerToClientEvents>
      ) => {
        if (
          !("roomId" in socket.handshake.query) ||
          typeof socket.handshake.query.roomId !== "string"
        ) {
          socket.disconnect()
          return
        }

        const roomId = socket.handshake.query.roomId.toLowerCase()
        const userName = typeof socket.handshake.query.userName === "string" 
          ? socket.handshake.query.userName 
          : undefined
        const isPublic = socket.handshake.query.isPublic === "true"
        
        const log = (...props: any[]) => {
          console.log(
            "[" + new Date().toUTCString() + "][room " + roomId + "]",
            socket.id,
            ...props
          )
        }

        if (!(await roomExists(roomId))) {
          await createNewRoom(roomId, socket.id, userName, isPublic)
          log("created room", { userName, isPublic })
        }

        socket.join(roomId)
        await incUsers()
        log("joined")

        await createNewUser(roomId, socket.id, userName)

        // Send initial chat history to the newly joined socket
        {
          const r = await getRoom(roomId)
          if (r) {
            io.to(socket.id).emit("chatHistory", r.chatLog ?? [])
          }
        }

        // Simple chat rate limiting per-socket
        let lastChatAt = 0

        socket.on("disconnect", async () => {
          await decUsers()
          log("disconnected")
          const room = await getRoom(roomId)
          if (room === null) return

          room.users = room.users.filter(
            (user) => user.socketIds[0] !== socket.id
          )
          if (room.users.length === 0) {
            await deleteRoom(roomId)
            log("deleted empty room")
          } else {
            if (room.ownerId === socket.id) {
              room.ownerId = room.users[0].uid
            }
            await broadcast(room)
          }
        })

        socket.on("setPaused", async (paused) => {
          let room = await getRoom(roomId)
          if (room === null) {
            throw new Error("Setting pause for non existing room:" + roomId)
          }
          log("set paused to", paused)

          room = updateLastSync(room)
          room.targetState.paused = paused
          await broadcast(room)
        })

        socket.on("setLoop", async (loop) => {
          const room = await getRoom(roomId)
          if (room === null) {
            throw new Error("Setting loop for non existing room:" + roomId)
          }
          log("set loop to", loop)

          room.targetState.loop = loop
          await broadcast(updateLastSync(room))
        })

        socket.on("setProgress", async (progress) => {
          const room = await getRoom(roomId)
          if (room === null) {
            throw new Error("Setting progress for non existing room:" + roomId)
          }

          room.users = room.users.map((user) => {
            if (user.socketIds[0] === socket.id) {
              user.player.progress = progress
            }
            return user
          })

          await broadcast(room)
        })

        socket.on("setPlaybackRate", async (playbackRate) => {
          let room = await getRoom(roomId)
          if (room === null) {
            throw new Error(
              "Setting playbackRate for non existing room:" + roomId
            )
          }
          log("set playbackRate to", playbackRate)

          room = updateLastSync(room)
          room.targetState.playbackRate = playbackRate
          await broadcast(room)
        })

        socket.on("seek", async (progress) => {
          const room = await getRoom(roomId)
          if (room === null) {
            throw new Error("Setting progress for non existing room:" + roomId)
          }
          log("seeking to", progress)

          room.targetState.progress = progress
          room.targetState.lastSync = new Date().getTime() / 1000
          await broadcast(room)
        })

        socket.on("playEnded", async () => {
          let room = await getRoom(roomId)
          if (room === null) {
            throw new Error("Play ended for non existing room:" + roomId)
          }
          log("playback ended")

          if (room.targetState.loop) {
            // Loop mode: restart the current video without modifying playlist
            room.targetState.progress = 0
            room.targetState.paused = false
          } else if (
            room.targetState.playlist.currentIndex + 1 <
            room.targetState.playlist.items.length
          ) {
            // Auto-advance to next item: play next video and remove finished one
            // This condition ensures there IS a next item before we splice
            const currentIdx = room.targetState.playlist.currentIndex
            
            // Get the next item before removing current
            room.targetState.playing =
              room.targetState.playlist.items[currentIdx + 1]
            
            // Remove the finished item from playlist (shift remaining items left)
            room.targetState.playlist.items.splice(currentIdx, 1)
            
            // Reset currentIndex to 0 since items have shifted
            // (the next item is now at position 0)
            room.targetState.playlist.currentIndex = 0
            room.targetState.progress = 0
            room.targetState.paused = false
            log("Removed finished item from playlist, shifted remaining items")
          } else {
            // Last item finished: pause at end
            room.targetState.progress =
              room.users.find((user) => user.socketIds[0] === socket.id)?.player
                .progress || 0
            room.targetState.paused = true
          }
          room.targetState.lastSync = new Date().getTime() / 1000
          await broadcast(room)
        })

        socket.on("playAgain", async () => {
          let room = await getRoom(roomId)
          if (room === null) {
            throw new Error("Play again for non existing room:" + roomId)
          }
          log("play same media again")

          room.targetState.progress = 0
          room.targetState.paused = false
          room.targetState.lastSync = new Date().getTime() / 1000
          await broadcast(room)
        })

        socket.on("playItemFromPlaylist", async (index) => {
          let room = await getRoom(roomId)
          if (room === null) {
            throw new Error("Play ended for non existing room:" + roomId)
          }

          if (index < 0 || index >= room.targetState.playlist.items.length) {
            return log(
              "out of index:",
              index,
              "playlist.length:",
              room.targetState.playlist.items.length
            )
          }

          log("playing item", index, "from playlist")
          room.targetState.playing = room.targetState.playlist.items[index]
          room.targetState.playlist.currentIndex = index
          room.targetState.progress = 0
          room.targetState.lastSync = new Date().getTime() / 1000
          await broadcast(room)
        })

        socket.on("updatePlaylist", async (playlist: Playlist) => {
          const room = await getRoom(roomId)
          if (room === null) {
            throw new Error("Setting playlist for non existing room:" + roomId)
          }
          log("playlist update", playlist)

          if (
            playlist.currentIndex < -1 ||
            playlist.currentIndex >= playlist.items.length
          ) {
            return log(
              "out of index:",
              playlist.currentIndex,
              "playlist.length:",
              playlist.items.length
            )
          }

          room.targetState.playlist = playlist
          await broadcast(room)
        })

        socket.on("updateUser", async (user: UserState) => {
          const room = await getRoom(roomId)
          if (room === null) {
            throw new Error("Setting user for non existing room:" + roomId)
          }
          log("user update", user)

          room.users = room.users.map((u) => {
            if (u.socketIds[0] !== socket.id) {
              return u
            }
            if (u.avatar !== user.avatar) {
              u.avatar = user.avatar
            }
            if (u.name !== user.name) {
              u.name = user.name
            }
            return u
          })

          await broadcast(room)
        })

        socket.on("playUrl", async (url) => {
          const room = await getRoom(roomId)
          if (room === null) {
            throw new Error(
              "Impossible non existing room, cannot send anything:" + roomId
            )
          }
          log("playing url", url)

          if (!isUrl(url)) {
            return
          }

          // Remove default image/video from playlist if it's the only item
          const defaultImg = getDefaultImg()
          const defaultMedia = defaultImg || getDefaultSrc()
          
          if (room.targetState.playlist.items.length === 1) {
            const firstItem = room.targetState.playlist.items[0]
            if (firstItem?.src?.[0]?.src === defaultMedia) {
              // Remove the default item
              room.targetState.playlist.items = []
              log("Removed default media from playlist")
            }
          }

          // Replace the currently playing video with the new one
          // If there's a current video at index 0, replace it; otherwise add new
          const newMedia = createMediaElement(url)
          
          if (room.targetState.playlist.currentIndex >= 0 && 
              room.targetState.playlist.items.length > 0) {
            // Replace the currently playing video at currentIndex
            const currentIdx = room.targetState.playlist.currentIndex
            room.targetState.playlist.items[currentIdx] = newMedia
          } else {
            // No current video, add as first item
            room.targetState.playlist.items.unshift(newMedia)
            room.targetState.playlist.currentIndex = 0
          }
          
          room.targetState.playing = newMedia
          room.targetState.progress = 0
          room.targetState.lastSync = new Date().getTime() / 1000
          room.targetState.paused = false
          await broadcast(room)
        })

        // Add a URL to playlist without immediate playback
        socket.on("addToPlaylist", async (url) => {
          const room = await getRoom(roomId)
          if (room === null) {
            throw new Error(
              "Impossible non existing room, cannot add to playlist:" + roomId
            )
          }
          if (!isUrl(url)) return log("addToPlaylist invalid url", url)
          log("add to playlist", url)

          room.targetState.playlist.items.push(createMediaElement(url))

          await broadcast(room)
        })

        socket.on("setMusicMode", async (musicMode: boolean) => {
          const room = await getRoom(roomId)
          if (room === null) {
            throw new Error("Setting music mode for non-existent room:" + roomId)
          }
          
          // Only allow room owner to toggle music mode
          if (socket.id !== room.ownerId) {
            log("attempted to toggle music mode but not owner")
            return
          }
          
          log("set music mode to", musicMode)
          room.musicMode = musicMode
          await broadcast(room)
        })

        socket.on("fetch", async () => {
          const room = await getRoom(roomId)
          if (room === null) {
            throw new Error(
              "Impossible non existing room, cannot send anything:" + roomId
            )
          }

          room.serverTime = new Date().getTime()
          socket.emit("update", room)
        })

        // ===== Chat events =====
        socket.on("chatMessage", async (text: string) => {
          try {
            const now = Date.now()
            // Basic rate limiting: 1 message every 750ms
            if (now - lastChatAt < 750) return
            lastChatAt = now

            const msgText = (text || "").toString().trim()
            if (!msgText) return
            if (msgText.length > 500) return

            const room = await getRoom(roomId)
            if (room === null) return

            // Find sender's display name
            const sender = room.users.find((u) => u.socketIds[0] === socket.id)
            const name = sender?.name ?? "Anonymous"

            const msg: ChatMessage = {
              id: `${now}-${socket.id}`,
              userId: socket.id,
              name,
              text: msgText,
              ts: now,
            }

            room.chatLog = [...(room.chatLog ?? []), msg].slice(-200)
            await setRoom(roomId, room)

            io.to(roomId).emit("chatNew", msg)
          } catch (e) {
            console.error("chatMessage failed:", e)
          }
        })
        // =======================
      }
    )

    // @ts-ignore
    res.socket.server.io = io
  }

  res.end()
}

export const config = {
  api: {
    bodyParser: false,
  },
}

export default ioHandler