webstreamnew / pages /api /socketio.ts
copilot-swe-agent[bot]
Fix video player controls and playlist behavior
6fc62e6
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