diff --git a/.codex/project.md b/.codex/project.md index 6a3234a..a60ca78 100644 --- a/.codex/project.md +++ b/.codex/project.md @@ -2,7 +2,7 @@ ## Project -`WatchLink` is a Dockerized Next.js + Postgres web app for persistent shared watch rooms with accounts, friends, roles, permissions, admin setup, and realtime playback sync. +`WatchLink` is a Dockerized Next.js + Postgres web app for persistent shared watch rooms with accounts, friends, roles, permissions, admin setup, persistent realtime playback sync, invites, room chat, and profile uploads. Repository: @@ -37,6 +37,12 @@ Database setup: Prisma migrations live in prisma/migrations and are applied in Docker with prisma migrate deploy. ``` +Runtime uploads: + +```text +Avatar uploads are stored in /app/public/uploads and mounted through the avatar-uploads Docker volume. +``` + Package manager: ```text diff --git a/.env.example b/.env.example index cf98e57..8253da8 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,4 @@ POSTGRES_USER="watchlink" POSTGRES_PASSWORD="watchlink" HOST_PORT="3000" # Uploaded avatars are stored in the Docker volume `avatar-uploads`. +# YouTube embeds may require viewers to open the video on YouTube when Google shows a sign-in or bot-check challenge. diff --git a/README.md b/README.md index 9896562..48c2369 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,12 @@ WatchLink is a self-hosted shared-watch web app with persistent user rooms, loca - First-run setup creates the first admin user. - Username/password accounts with bcrypt password hashing. - Every user gets a stable personal room like `/rooms/@username`. -- Friend requests and room access are modeled for friends-only, public, role-restricted, and explicit access. -- Admin surface for rooms, users, roles, and permissions. +- Friend requests and room access for friends-only, public, role-restricted, and explicit access. +- Admin surface for rooms, users, roles, invites, security settings, and audit events. - Site-wide instance settings for registration mode, default room visibility, media providers, and avatar upload limits. -- Profile settings with display name and profile picture upload. -- Shared playback state via Socket.IO. +- Profile settings with display name, profile picture upload, password change, and logout. +- Shared playback state via Socket.IO with Postgres-persisted room state. +- Persistent room chat and queue updates through the realtime room channel. - Media URL normalization for YouTube, Twitch, and direct video URLs. - Uptime Kuma/Dockge-inspired app shell with system light/dark theme. - Docker Compose stack for app and Postgres. @@ -115,6 +116,14 @@ The Compose stack exposes the web app on `http://localhost:${HOST_PORT:-3000}` a On first start, the web container runs `prisma migrate deploy` before starting Next.js. This creates the required tables in a clean Postgres volume. +### Provider notes + +WatchLink uses the official YouTube IFrame API. If YouTube shows a sign-in or bot-check challenge inside the embed, WatchLink cannot bypass that provider-side restriction; the room shows a fallback action to open the video on YouTube. Twitch live streams support source and play/pause sync, while seek sync is available only for Twitch VODs and direct video files. + +### Uploads and invites + +Uploaded avatars are stored below `/app/public/uploads` in the `avatar-uploads` Docker volume. Invite-only registration uses invite codes created in the Admin area; room-scoped invite codes also grant room membership when the account is created. + Build and publish the Gitea image: ```bash diff --git a/prisma/migrations/20260515224500_v1_completion/migration.sql b/prisma/migrations/20260515224500_v1_completion/migration.sql new file mode 100644 index 0000000..83d2bef --- /dev/null +++ b/prisma/migrations/20260515224500_v1_completion/migration.sql @@ -0,0 +1,59 @@ +-- WatchLink V1 completion support: persistent realtime state, messages, audit, +-- invites, disabled users, and protected personal rooms. + +CREATE TYPE "InviteStatus" AS ENUM ('ACTIVE', 'REVOKED', 'USED', 'EXPIRED'); + +ALTER TABLE "User" ADD COLUMN "disabledAt" TIMESTAMP(3); +ALTER TABLE "Room" ADD COLUMN "isPersonal" BOOLEAN NOT NULL DEFAULT false; + +UPDATE "Room" +SET "isPersonal" = true +WHERE "slug" LIKE '@%'; + +CREATE TABLE "RoomMessage" ( + "id" TEXT NOT NULL, + "roomId" TEXT NOT NULL, + "userId" TEXT, + "body" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RoomMessage_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "AuditEvent" ( + "id" TEXT NOT NULL, + "actorId" TEXT, + "roomId" TEXT, + "action" TEXT NOT NULL, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuditEvent_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "Invite" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "roomId" TEXT, + "creatorId" TEXT, + "status" "InviteStatus" NOT NULL DEFAULT 'ACTIVE', + "expiresAt" TIMESTAMP(3), + "usedById" TEXT, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Invite_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "Invite_code_key" ON "Invite"("code"); +CREATE INDEX "RoomMessage_roomId_createdAt_idx" ON "RoomMessage"("roomId", "createdAt"); +CREATE INDEX "AuditEvent_roomId_createdAt_idx" ON "AuditEvent"("roomId", "createdAt"); +CREATE INDEX "AuditEvent_actorId_createdAt_idx" ON "AuditEvent"("actorId", "createdAt"); +CREATE INDEX "Invite_roomId_status_idx" ON "Invite"("roomId", "status"); + +ALTER TABLE "RoomMessage" ADD CONSTRAINT "RoomMessage_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "RoomMessage" ADD CONSTRAINT "RoomMessage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "AuditEvent" ADD CONSTRAINT "AuditEvent_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "AuditEvent" ADD CONSTRAINT "AuditEvent_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "Invite" ADD CONSTRAINT "Invite_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Invite" ADD CONSTRAINT "Invite_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e907a51..928d0b8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -33,12 +33,20 @@ enum MediaProvider { UNKNOWN } +enum InviteStatus { + ACTIVE + REVOKED + USED + EXPIRED +} + model User { id String @id @default(cuid()) username String @unique passwordHash String displayName String? avatarUrl String? + disabledAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt roles UserRole[] @@ -47,6 +55,9 @@ model User { gotFriends Friendship[] @relation("FriendReceiver") roomMembers RoomMember[] submitted MediaSource[] + messages RoomMessage[] + auditEvents AuditEvent[] + invites Invite[] } model AppSetting { @@ -108,6 +119,7 @@ model Room { slug String @unique name String ownerId String? + isPersonal Boolean @default(false) visibility RoomVisibility @default(FRIENDS) currentState Json? createdAt DateTime @default(now()) @@ -115,6 +127,9 @@ model Room { owner User? @relation("RoomOwner", fields: [ownerId], references: [id], onDelete: SetNull) members RoomMember[] mediaSources MediaSource[] + messages RoomMessage[] + auditEvents AuditEvent[] + invites Invite[] } model RoomMember { @@ -141,3 +156,45 @@ model MediaSource { room Room @relation(fields: [roomId], references: [id], onDelete: Cascade) submitter User? @relation(fields: [submitterId], references: [id], onDelete: SetNull) } + +model RoomMessage { + id String @id @default(cuid()) + roomId String + userId String? + body String + createdAt DateTime @default(now()) + room Room @relation(fields: [roomId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([roomId, createdAt]) +} + +model AuditEvent { + id String @id @default(cuid()) + actorId String? + roomId String? + action String + metadata Json? + createdAt DateTime @default(now()) + actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull) + room Room? @relation(fields: [roomId], references: [id], onDelete: SetNull) + + @@index([roomId, createdAt]) + @@index([actorId, createdAt]) +} + +model Invite { + id String @id @default(cuid()) + code String @unique + roomId String? + creatorId String? + status InviteStatus @default(ACTIVE) + expiresAt DateTime? + usedById String? + usedAt DateTime? + createdAt DateTime @default(now()) + room Room? @relation(fields: [roomId], references: [id], onDelete: Cascade) + creator User? @relation(fields: [creatorId], references: [id], onDelete: SetNull) + + @@index([roomId, status]) +} diff --git a/server.js b/server.js index 0ea89e3..136c3ba 100644 --- a/server.js +++ b/server.js @@ -1,70 +1,495 @@ +const { createHmac, timingSafeEqual, randomBytes } = require("node:crypto"); const { createServer } = require("node:http"); +const { PrismaClient } = require("@prisma/client"); const next = require("next"); const { Server } = require("socket.io"); const dev = process.env.NODE_ENV !== "production"; const hostname = process.env.HOSTNAME || "0.0.0.0"; const port = Number(process.env.PORT || 3000); +const sessionCookie = "watchlink_session"; +const prisma = new PrismaClient(); const app = next({ dev, hostname, port }); const handle = app.getRequestHandler(); - -const roomStates = new Map(); +const presence = new Map(); app.prepare().then(() => { const httpServer = createServer((req, res) => handle(req, res)); const io = new Server(httpServer, { path: "/api/socket", cors: { - origin: process.env.NEXTAUTH_URL || "http://localhost:3000" + origin: process.env.NEXTAUTH_URL || "http://localhost:3000", + credentials: true } }); io.on("connection", (socket) => { - socket.on("room:join", ({ roomSlug, user }) => { - if (!roomSlug) return; - socket.join(roomSlug); - socket.data.roomSlug = roomSlug; - socket.data.user = user || "Guest"; - socket.emit("room:state", roomStates.get(roomSlug) || null); - socket.to(roomSlug).emit("presence:join", { user: socket.data.user }); + socket.on("room:join", async ({ roomSlug } = {}) => { + await safeSocket(socket, async () => { + const session = await getSocketSession(socket); + if (!session || !roomSlug) return reject(socket, "Sign in to join this room."); + const context = await getRoomContext(roomSlug, session.user.id); + if (!context.allowed) return reject(socket, "You do not have access to this room."); + + socket.data.user = session.user; + socket.data.roomSlug = context.room.slug; + socket.data.roomId = context.room.id; + socket.join(context.room.slug); + addPresence(context.room.slug, session.user, socket.id); + + socket.emit("room:state", await buildRoomSnapshot(context.room.id)); + io.to(context.room.slug).emit("presence:list", getPresence(context.room.slug)); + }); }); - socket.on("media:set", (payload) => updateRoom(socket, "media:set", payload)); - socket.on("playback:play", (payload) => updateRoom(socket, "playback:play", payload)); - socket.on("playback:pause", (payload) => updateRoom(socket, "playback:pause", payload)); - socket.on("playback:seek", (payload) => updateRoom(socket, "playback:seek", payload)); - socket.on("chat:message", (payload) => relayRoom(socket, "chat:message", payload)); + socket.on("queue:add", (payload) => safeRoomAction(socket, async ({ room, user }) => { + const sourceUrl = String(payload?.sourceUrl || "").trim(); + if (!sourceUrl) return; + const settings = await getAppSettings(); + const media = normalizeMediaUrl(sourceUrl); + if (!settings.allowedProviders.includes(media.provider)) return reject(socket, "This media provider is disabled."); + + const nextPosition = await prisma.mediaSource.count({ where: { roomId: room.id } }); + const created = await prisma.mediaSource.create({ + data: { + roomId: room.id, + submitterId: user.id, + provider: media.provider, + originalUrl: media.originalUrl, + playbackUrl: media.playbackUrl, + thumbnailUrl: media.thumbnailUrl, + queuePosition: nextPosition + 1, + title: media.title || media.originalUrl + } + }); + + if (!room.currentState) { + await persistPlaybackState(room.id, user, { + mediaSourceId: created.id, + status: "PAUSED", + position: 0, + rate: 1 + }); + } + + await audit("room.queue.add", user.id, room.id, { mediaSourceId: created.id, provider: media.provider }); + await broadcastRoom(io, room.slug, room.id); + })); + + socket.on("queue:play", (payload) => safeRoomAction(socket, async ({ room, user }) => { + const mediaSourceId = String(payload?.mediaSourceId || ""); + const media = await prisma.mediaSource.findFirst({ where: { id: mediaSourceId, roomId: room.id } }); + if (!media) return; + await persistPlaybackState(room.id, user, { mediaSourceId: media.id, status: "PLAYING", position: 0, rate: 1 }); + await audit("room.queue.play", user.id, room.id, { mediaSourceId: media.id }); + await broadcastRoom(io, room.slug, room.id); + })); + + socket.on("queue:remove", (payload) => safeRoomAction(socket, async ({ room, user }) => { + const mediaSourceId = String(payload?.mediaSourceId || ""); + const media = await prisma.mediaSource.findFirst({ where: { id: mediaSourceId, roomId: room.id } }); + if (!media) return; + await prisma.mediaSource.delete({ where: { id: media.id } }); + await normalizeQueue(room.id); + + const current = parseState(room.currentState); + if (current?.mediaSourceId === media.id) { + const nextMedia = await prisma.mediaSource.findFirst({ + where: { roomId: room.id }, + orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }] + }); + await prisma.room.update({ + where: { id: room.id }, + data: { + currentState: nextMedia + ? playbackState(user, { mediaSourceId: nextMedia.id, status: "PAUSED", position: 0, rate: 1 }) + : null + } + }); + } + + await audit("room.queue.remove", user.id, room.id, { mediaSourceId: media.id }); + await broadcastRoom(io, room.slug, room.id); + })); + + socket.on("queue:move", (payload) => safeRoomAction(socket, async ({ room, user }) => { + const mediaSourceId = String(payload?.mediaSourceId || ""); + const direction = payload?.direction === "down" ? 1 : -1; + await moveMedia(room.id, mediaSourceId, direction); + await audit("room.queue.move", user.id, room.id, { mediaSourceId, direction }); + await broadcastRoom(io, room.slug, room.id); + })); + + socket.on("playback:play", (payload) => safeRoomAction(socket, async ({ room, user }) => { + await persistPlaybackState(room.id, user, { + mediaSourceId: String(payload?.mediaSourceId || parseState(room.currentState)?.mediaSourceId || ""), + status: "PLAYING", + position: Number(payload?.position || 0), + rate: 1 + }); + await broadcastRoom(io, room.slug, room.id); + })); + + socket.on("playback:pause", (payload) => safeRoomAction(socket, async ({ room, user }) => { + await persistPlaybackState(room.id, user, { + mediaSourceId: String(payload?.mediaSourceId || parseState(room.currentState)?.mediaSourceId || ""), + status: "PAUSED", + position: Number(payload?.position || 0), + rate: 1 + }); + await broadcastRoom(io, room.slug, room.id); + })); + + socket.on("playback:seek", (payload) => safeRoomAction(socket, async ({ room, user }) => { + const previous = parseState(room.currentState); + await persistPlaybackState(room.id, user, { + mediaSourceId: String(payload?.mediaSourceId || previous?.mediaSourceId || ""), + status: previous?.status || "PAUSED", + position: Number(payload?.position || 0), + rate: 1 + }); + await broadcastRoom(io, room.slug, room.id); + })); + + socket.on("chat:message", (payload) => safeRoomAction(socket, async ({ room, user }) => { + const body = String(payload?.body || "").trim().slice(0, 1000); + if (!body) return; + await prisma.roomMessage.create({ data: { roomId: room.id, userId: user.id, body } }); + await audit("room.chat.message", user.id, room.id, {}); + await broadcastRoom(io, room.slug, room.id); + })); socket.on("disconnect", () => { - if (socket.data.roomSlug) { - socket.to(socket.data.roomSlug).emit("presence:leave", { user: socket.data.user || "Guest" }); + const roomSlug = socket.data.roomSlug; + const user = socket.data.user; + if (roomSlug && user) { + removePresence(roomSlug, socket.id); + io.to(roomSlug).emit("presence:list", getPresence(roomSlug)); } }); }); - function relayRoom(socket, event, payload) { - const roomSlug = socket.data.roomSlug; - if (!roomSlug) return; - io.to(roomSlug).emit(event, { ...payload, user: socket.data.user || "Guest", at: Date.now() }); - } - - function updateRoom(socket, event, payload) { - const roomSlug = socket.data.roomSlug; - if (!roomSlug) return; - const previous = roomStates.get(roomSlug) || {}; - const nextState = { - ...previous, - ...payload, - lastEvent: event, - updatedBy: socket.data.user || "Guest", - updatedAt: Date.now() - }; - roomStates.set(roomSlug, nextState); - io.to(roomSlug).emit(event, nextState); - } - httpServer.listen(port, hostname, () => { console.log(`WatchLink ready on http://${hostname}:${port}`); }); }); + +async function safeSocket(socket, action) { + try { + await action(); + } catch (error) { + console.error(error); + reject(socket, "Realtime action failed."); + } +} + +async function safeRoomAction(socket, action) { + await safeSocket(socket, async () => { + const user = socket.data.user || (await getSocketSession(socket))?.user; + const roomSlug = socket.data.roomSlug; + if (!user || !roomSlug) return reject(socket, "Join a room before sending actions."); + const context = await getRoomContext(roomSlug, user.id); + if (!context.allowed) return reject(socket, "You do not have access to this room."); + await action({ room: context.room, user }); + }); +} + +function reject(socket, message) { + socket.emit("room:error", { message }); +} + +async function broadcastRoom(io, roomSlug, roomId) { + io.to(roomSlug).emit("room:state", await buildRoomSnapshot(roomId)); +} + +async function getSocketSession(socket) { + const cookies = parseCookies(socket.handshake.headers.cookie || ""); + const raw = cookies[sessionCookie]; + if (!raw) return null; + const [userId, signature] = raw.split("."); + if (!userId || !signature || !verifySignature(userId, signature)) return null; + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { roles: { include: { role: true } } } + }); + if (!user || user.disabledAt) return null; + return { user }; +} + +function parseCookies(header) { + return Object.fromEntries( + header + .split(";") + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => { + const index = part.indexOf("="); + return [decodeURIComponent(part.slice(0, index)), decodeURIComponent(part.slice(index + 1))]; + }) + ); +} + +function verifySignature(value, signature) { + const expected = createHmac("sha256", process.env.NEXTAUTH_SECRET || "development-only-change-me") + .update(value) + .digest("base64url"); + const expectedBuffer = Buffer.from(expected); + const actualBuffer = Buffer.from(signature); + return expectedBuffer.length === actualBuffer.length && timingSafeEqual(expectedBuffer, actualBuffer); +} + +async function getRoomContext(slug, userId) { + const room = await prisma.room.findUnique({ + where: { slug }, + include: { + owner: true, + members: { where: { userId }, select: { userId: true, canManage: true } } + } + }); + if (!room) return { allowed: false, room: null }; + const user = await prisma.user.findUnique({ where: { id: userId }, include: { roles: { include: { role: true } } } }); + const isAdmin = Boolean(user?.roles.some((userRole) => userRole.role.name === "admin")); + const isOwner = room.ownerId === userId; + const explicitMember = room.members.length > 0; + const isFriend = room.ownerId + ? Boolean( + await prisma.friendship.findFirst({ + where: { + status: "ACCEPTED", + OR: [ + { requesterId: userId, receiverId: room.ownerId }, + { requesterId: room.ownerId, receiverId: userId } + ] + }, + select: { id: true } + }) + ) + : false; + const allowed = + isAdmin || + isOwner || + room.visibility === "PUBLIC" || + (room.visibility === "FRIENDS" && (isFriend || explicitMember)) || + (room.visibility === "EXPLICIT" && explicitMember) || + (room.visibility === "ROLE_RESTRICTED" && explicitMember); + return { allowed, room }; +} + +async function buildRoomSnapshot(roomId) { + const room = await prisma.room.findUnique({ + where: { id: roomId }, + include: { + mediaSources: { + include: { submitter: true }, + orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }, { id: "asc" }], + take: 60 + }, + messages: { + include: { user: true }, + orderBy: { createdAt: "desc" }, + take: 50 + } + } + }); + if (!room) return null; + const queue = room.mediaSources.map((item) => serializeMedia(item)); + const state = hydrateState(parseState(room.currentState), queue); + return { + roomId: room.id, + roomSlug: room.slug, + queue, + playback: state, + messages: room.messages + .slice() + .reverse() + .map((message) => ({ + id: message.id, + body: message.body, + createdAt: message.createdAt.toISOString(), + user: message.user?.displayName || message.user?.username || "Deleted user", + avatarUrl: message.user?.avatarUrl || null + })) + }; +} + +function hydrateState(state, queue) { + const current = queue.find((item) => item.id === state?.mediaSourceId) || queue[0] || null; + if (!current) return null; + const updatedAt = Number(state?.updatedAt || Date.now()); + const basePosition = Number(state?.position || 0); + const status = state?.status === "PLAYING" ? "PLAYING" : "PAUSED"; + const livePosition = status === "PLAYING" ? basePosition + Math.max(0, Date.now() - updatedAt) / 1000 : basePosition; + return { + ...state, + mediaSourceId: current.id, + media: current, + status, + position: livePosition, + rate: Number(state?.rate || 1), + updatedAt + }; +} + +function serializeMedia(item) { + return { + id: item.id, + title: item.title || item.originalUrl, + provider: item.provider, + originalUrl: item.originalUrl, + playbackUrl: item.playbackUrl, + thumbnailUrl: item.thumbnailUrl, + by: item.submitter?.displayName || item.submitter?.username || "Unknown", + createdAt: new Intl.DateTimeFormat("en", { month: "short", day: "numeric" }).format(item.createdAt) + }; +} + +async function persistPlaybackState(roomId, user, input) { + if (!input.mediaSourceId) return; + const media = await prisma.mediaSource.findFirst({ where: { id: input.mediaSourceId, roomId } }); + if (!media) return; + await prisma.room.update({ + where: { id: roomId }, + data: { + currentState: playbackState(user, input) + } + }); +} + +function playbackState(user, input) { + return { + mediaSourceId: input.mediaSourceId, + status: input.status === "PLAYING" ? "PLAYING" : "PAUSED", + position: Math.max(0, Number(input.position || 0)), + rate: Number(input.rate || 1), + updatedBy: user.username, + updatedById: user.id, + updatedAt: Date.now() + }; +} + +function parseState(value) { + return value && typeof value === "object" ? value : null; +} + +async function moveMedia(roomId, mediaSourceId, direction) { + const queue = await prisma.mediaSource.findMany({ + where: { roomId }, + orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }, { id: "asc" }], + select: { id: true } + }); + const index = queue.findIndex((item) => item.id === mediaSourceId); + const target = index + direction; + if (index < 0 || target < 0 || target >= queue.length) return; + const reordered = [...queue]; + [reordered[index], reordered[target]] = [reordered[target], reordered[index]]; + await prisma.$transaction( + reordered.map((item, itemIndex) => + prisma.mediaSource.update({ where: { id: item.id }, data: { queuePosition: itemIndex + 1 } }) + ) + ); +} + +async function normalizeQueue(roomId) { + const queue = await prisma.mediaSource.findMany({ + where: { roomId }, + orderBy: [{ queuePosition: "asc" }, { createdAt: "asc" }, { id: "asc" }], + select: { id: true } + }); + await prisma.$transaction( + queue.map((item, index) => + prisma.mediaSource.update({ where: { id: item.id }, data: { queuePosition: index + 1 } }) + ) + ); +} + +function addPresence(roomSlug, user, socketId) { + const rows = presence.get(roomSlug) || new Map(); + const existing = rows.get(user.id); + rows.set(user.id, { + id: user.id, + name: user.displayName || user.username, + avatarUrl: user.avatarUrl || null, + status: "Online", + sockets: new Set([...(existing?.sockets || []), socketId]) + }); + presence.set(roomSlug, rows); +} + +function removePresence(roomSlug, socketId) { + const rows = presence.get(roomSlug); + if (!rows) return; + for (const user of rows.values()) { + user.sockets.delete(socketId); + if (user.sockets.size === 0) rows.delete(user.id); + } +} + +function getPresence(roomSlug) { + return [...(presence.get(roomSlug)?.values() || [])].map(({ sockets, ...user }) => user); +} + +async function getAppSettings() { + const rows = await prisma.appSetting.findMany(); + const values = new Map(rows.map((row) => [row.key, row.value])); + return { + allowedProviders: (values.get("allowedProviders") || "YOUTUBE,TWITCH,DIRECT") + .split(",") + .map((item) => item.trim()) + .filter(Boolean) + }; +} + +async function audit(action, actorId, roomId, metadata) { + await prisma.auditEvent.create({ data: { action, actorId, roomId, metadata } }).catch(() => {}); +} + +function normalizeMediaUrl(input) { + const originalUrl = input.trim(); + try { + const url = new URL(originalUrl); + const host = url.hostname.replace(/^www\./, ""); + if (host === "youtu.be") { + return youtubeMedia(originalUrl, url.pathname.slice(1)); + } + if (host.endsWith("youtube.com")) { + return youtubeMedia(originalUrl, url.searchParams.get("v") || url.pathname.split("/").filter(Boolean).pop()); + } + if (host.endsWith("twitch.tv")) { + const parts = url.pathname.split("/").filter(Boolean); + if (parts[0] === "videos" && parts[1]) { + return { + provider: "TWITCH", + originalUrl, + playbackUrl: `https://player.twitch.tv/?video=${parts[1]}&parent=localhost`, + thumbnailUrl: "/icon.svg", + title: originalUrl + }; + } + if (parts[0]) { + return { + provider: "TWITCH", + originalUrl, + playbackUrl: `https://player.twitch.tv/?channel=${parts[0]}&parent=localhost`, + thumbnailUrl: "/icon.svg", + title: originalUrl + }; + } + } + if (/\.(mp4|webm|ogg|mov|m4v)(\?.*)?$/i.test(url.pathname)) { + return { provider: "DIRECT", originalUrl, playbackUrl: originalUrl, thumbnailUrl: "/icon.svg", title: originalUrl }; + } + } catch {} + return { provider: "UNKNOWN", originalUrl, playbackUrl: originalUrl, thumbnailUrl: "/icon.svg", title: originalUrl }; +} + +function youtubeMedia(originalUrl, id) { + const videoId = String(id || "").replace(/[^a-zA-Z0-9_-]/g, ""); + return { + provider: videoId ? "YOUTUBE" : "UNKNOWN", + originalUrl, + playbackUrl: videoId ? `https://www.youtube.com/embed/${videoId}?enablejsapi=1&playsinline=1` : originalUrl, + thumbnailUrl: videoId ? `https://img.youtube.com/vi/${videoId}/hqdefault.jpg` : "/icon.svg", + title: originalUrl + }; +} diff --git a/src/app/account/settings/page.tsx b/src/app/account/settings/page.tsx index e7438f7..b3cdd30 100644 --- a/src/app/account/settings/page.tsx +++ b/src/app/account/settings/page.tsx @@ -1,7 +1,7 @@ import { AppShell } from "@/components/app-shell"; import { Avatar } from "@/components/avatar"; import { EmptyState, PageHeader, Panel, StatusDot } from "@/components/ui"; -import { updateProfile } from "@/lib/profile-actions"; +import { changePassword, updateProfile } from "@/lib/profile-actions"; import { getShellContext } from "@/lib/shell"; import { requireCurrentUser } from "@/lib/session"; import { getAppSettings } from "@/lib/settings"; @@ -33,6 +33,7 @@ export default async function AccountSettingsPage({ searchParams }: { searchPara {saved ?

Profile updated.

: null} {error === "avatar" ?

Upload a PNG, JPG, WEBP, or GIF below {settings.maxAvatarUploadMb} MB.

: null} + {error === "password" ?

Check your current password and use a matching new password with at least 10 characters.

: null}
@@ -55,6 +56,24 @@ export default async function AccountSettingsPage({ searchParams }: { searchPara + + {saved === "password" ?

Password changed.

: null} + + + + + + +
); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 1128962..1aa0954 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -12,6 +12,8 @@ import { requireInitialSetup } from "@/lib/setup"; import { DataTable, EmptyState, MetricTile, PageHeader, Panel, StatusDot, Tabs } from "@/components/ui"; import { getAppSettings, type AppSettings } from "@/lib/settings"; import { updateInstanceSettings, updateSecuritySettings } from "@/lib/settings-actions"; +import { createInstanceInvite, disableUser, enableUser, grantAdminRole, revokeAdminRole, revokeInvite } from "@/lib/admin-actions"; +import { deleteRoom } from "@/lib/room-actions"; export const dynamic = "force-dynamic"; @@ -30,7 +32,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis const query = q.trim().toLowerCase(); const shell = await getShellContext(user); - const [users, rooms, roles, pendingRequests, appSettings] = await Promise.all([ + const [users, rooms, roles, pendingRequests, appSettings, invites, auditEvents] = await Promise.all([ prisma.user.findMany({ include: { roles: { include: { role: true } }, _count: { select: { ownedRooms: true, roomMembers: true } } }, orderBy: { createdAt: "asc" } @@ -44,7 +46,9 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis orderBy: { name: "asc" } }), prisma.friendship.count({ where: { status: "PENDING" } }), - getAppSettings() + getAppSettings(), + prisma.invite.findMany({ include: { room: true, creator: true }, orderBy: { createdAt: "desc" }, take: 50 }), + prisma.auditEvent.findMany({ include: { actor: true, room: true }, orderBy: { createdAt: "desc" }, take: 20 }) ]); const filteredUsers = query ? users.filter((account) => @@ -74,8 +78,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis } actions={ <> - - + Invite + Audit } /> @@ -102,29 +106,32 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis > ({ label: item, href: `/admin?tab=${encodeURIComponent(item)}` }))} /> - {activeTab === "Users" ? : null} + {activeTab === "Users" ? : null} {activeTab === "Rooms" ? : null} {activeTab === "Roles" ? : null} - {activeTab === "Invites" ? : null} + {activeTab === "Invites" ? : null} {activeTab === "Instance" ? : null} - {activeTab === "Security" ? : null} + {activeTab === "Security" ? : null} ); } function UsersTable({ - users + users, + currentUserId }: { users: Array<{ id: string; username: string; displayName: string | null; avatarUrl: string | null; + disabledAt: Date | null; createdAt: Date; roles: Array<{ roleId: string; role: { name: string } }>; _count: { ownedRooms: number; roomMembers: number }; }>; + currentUserId: string; }) { return ( @@ -161,8 +168,33 @@ function UsersTable({
{account._count.ownedRooms + account._count.roomMembers} - {formatDate(account.createdAt)} - + {account.disabledAt ? disabled : formatDate(account.createdAt)} + +
+ {account.roles.some((role) => role.role.name === "admin") ? ( +
+ + +
+ ) : ( +
+ + +
+ )} + {account.disabledAt ? ( +
+ + +
+ ) : ( +
+ + +
+ )} +
+ ))} @@ -179,6 +211,7 @@ function RoomsTable({ slug: string; name: string; visibility: string; + isPersonal: boolean; updatedAt: Date; owner: { username: string; displayName: string | null } | null; _count: { members: number; mediaSources: number }; @@ -210,7 +243,19 @@ function RoomsTable({ {room.visibility.toLowerCase().replace("_", " ")} {room._count.members + 1} users / {room._count.mediaSources} media {formatDate(room.updatedAt)} - Open + +
+ Open + {!room.isPersonal ? ( +
+ + +
+ ) : ( + personal + )} +
+ ))} @@ -260,11 +305,65 @@ function RolesTable({ ); } -function InvitesPanel({ pendingRequests }: { pendingRequests: number }) { +function InvitesPanel({ + pendingRequests, + invites, + rooms +}: { + pendingRequests: number; + invites: Array<{ + id: string; + code: string; + status: string; + expiresAt: Date | null; + createdAt: Date; + room: { id: string; name: string; slug: string } | null; + creator: { username: string; displayName: string | null } | null; + }>; + rooms: Array<{ id: string; name: string; slug: string }>; +}) { return (
- - + +
+ + + +
+
+ + {invites.length === 0 ? ( + + ) : ( +
+ {invites.map((invite) => ( +
+ + {invite.code} + {invite.room ? `${invite.room.name} /${invite.room.slug}` : "Instance invite"} - {formatDate(invite.createdAt)} + + {invite.status.toLowerCase()} + {invite.status === "ACTIVE" ? ( +
+ + +
+ ) : null} +
+ ))} +
+ )}
@@ -337,7 +436,21 @@ function InstancePanel({ userCount, roomCount, settings, saved }: { userCount: n ); } -function SecurityPanel({ settings, saved }: { settings: AppSettings; saved?: string }) { +function SecurityPanel({ + settings, + saved, + auditEvents +}: { + settings: AppSettings; + saved?: string; + auditEvents: Array<{ + id: string; + action: string; + createdAt: Date; + actor: { username: string; displayName: string | null } | null; + room: { slug: string; name: string } | null; + }>; +}) { return (
@@ -360,7 +473,27 @@ function SecurityPanel({ settings, saved }: { settings: AppSettings; saved?: str - +
+ + + +
+
+ + {auditEvents.length === 0 ? ( + + ) : ( +
+ {auditEvents.map((event) => ( +
+ + {event.action} + {event.actor?.displayName || event.actor?.username || "System"} - {event.room?.slug || "instance"} - {formatDate(event.createdAt)} + +
+ ))} +
+ )}
); diff --git a/src/app/globals.css b/src/app/globals.css index 5870dc5..a97f92c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -213,6 +213,11 @@ textarea { color: var(--accent-2); } +.badge.danger { + border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); + color: var(--danger); +} + .room-layout { display: grid; grid-template-columns: minmax(0, 1fr) 340px; @@ -242,6 +247,7 @@ textarea { } .video-frame { + position: relative; display: grid; min-height: 430px; place-items: center; @@ -274,6 +280,40 @@ textarea { border: 0; } +.youtube-embed iframe { + width: 100%; + height: 100%; + border: 0; +} + +.player-overlay { + position: absolute; + inset: 0; + z-index: 5; + display: grid; + place-content: center; + gap: 12px; + padding: 24px; + background: rgba(5, 7, 10, 0.78); + color: #e5edf7; + text-align: center; +} + +.player-overlay h2, +.player-overlay p { + margin: 0; +} + +.compact-overlay { + inset: auto 16px 16px 16px; + place-content: start; + justify-items: start; + border: 1px solid #263449; + border-radius: 8px; + background: rgba(15, 23, 42, 0.94); + text-align: left; +} + .video-state h2 { margin: 0 0 10px; font-size: 24px; @@ -687,6 +727,25 @@ textarea { padding: 12px; } +.provider-note { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + border-top: 1px solid var(--border); + padding: 8px 12px; + color: var(--muted); + font-size: 12px; +} + +.provider-note a { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--accent-2); + font-weight: 800; +} + .queue-row, .timeline-item, .action-row, @@ -700,6 +759,18 @@ textarea { padding: 10px; } +.queue-row.active-row { + border-color: color-mix(in srgb, var(--accent-2) 70%, var(--border)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent-2) 28%, transparent); +} + +.chat-messages { + display: grid; + max-height: 420px; + gap: 10px; + overflow: auto; +} + .timeline-item.interactive:hover, .action-row:hover { border-color: color-mix(in srgb, var(--accent-2) 45%, var(--border)); @@ -850,6 +921,11 @@ textarea { color: var(--danger); } +.button.danger { + border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); + color: var(--danger); +} + .text-link, .inline-meta { display: inline-flex; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 1dd0d07..2069748 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -28,7 +28,7 @@ export default async function LoginPage({ searchParams }: { searchParams: Promis

Login

Enter your account to open rooms and manage playback.

- {error ?

{error === "registration-closed" ? "Registration is currently disabled by the administrator." : "Invalid username or password."}

: null} + {error ?

{loginError(error)}

: null}
{friendship.status.toLowerCase()} - + {friendship.status === "PENDING" && friendship.requesterId === currentUserId ? ( + + + + + ) : null} + {friendship.status === "BLOCKED" ? ( +
+ + +
+ ) : null} + {friendship.status === "ACCEPTED" ? ( + <> +
+ + +
+
+ + +
+ + ) : null}
); @@ -207,12 +230,14 @@ function IncomingList({ requests }: { requests: FriendshipWithUsers[] }) { } function Directory({ - rows + rows, + currentUserId }: { rows: Array<{ user: DirectoryUser; relationship?: FriendshipWithUsers; }>; + currentUserId: string; }) { if (rows.length === 0) { return ; @@ -233,6 +258,7 @@ function Directory({ {rows.map(({ user, relationship }) => { const roomSlug = user.ownedRooms[0]?.slug; const accepted = relationship?.status === "ACCEPTED"; + const blocked = relationship?.status === "BLOCKED"; return ( @@ -248,11 +274,22 @@ function Directory({ {relationship?.status.toLowerCase() || "none"} {!relationship ? ( -
- - +
+ + + + +
+ + +
+
+ ) : blocked && relationship.requesterId === currentUserId ? ( +
+ +
) : null} diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx index 2aa7a30..193f1f4 100644 --- a/src/app/register/page.tsx +++ b/src/app/register/page.tsx @@ -11,7 +11,7 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro redirect("/setup"); } const settings = await getAppSettings(); - if (settings.registrationMode !== "OPEN") { + if (settings.registrationMode === "DISABLED") { redirect("/login?error=registration-closed"); } const { error } = await searchParams; @@ -41,6 +41,12 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro Password + {settings.registrationMode === "INVITE_ONLY" ? ( + + ) : null} @@ -57,5 +63,6 @@ export default async function RegisterPage({ searchParams }: { searchParams: Pro function registerError(error: string) { if (error === "username") return "This username is already taken."; if (error === "closed") return "Registration is currently disabled by the administrator."; + if (error === "invite") return "Use a valid active invite code."; return "Use a username and a password with at least 10 characters."; } diff --git a/src/app/rooms/[slug]/page.tsx b/src/app/rooms/[slug]/page.tsx index bbf685c..429dde9 100644 --- a/src/app/rooms/[slug]/page.tsx +++ b/src/app/rooms/[slug]/page.tsx @@ -95,6 +95,7 @@ export default async function RoomPage({ roomSlug={room.slug} roomName={room.name} roomVisibility={room.visibility} + isPersonal={room.isPersonal} ownerName={room.owner?.displayName || room.owner?.username || "Unassigned"} initialRail={rail} savedState={saved} diff --git a/src/app/rooms/page.tsx b/src/app/rooms/page.tsx index d218933..598181a 100644 --- a/src/app/rooms/page.tsx +++ b/src/app/rooms/page.tsx @@ -133,7 +133,7 @@ export default async function RoomsPage({ searchParams }: { searchParams: Promis title={`No ${activeView.toLowerCase()} rooms`} description={ activeView === "Invites" - ? "Room invite persistence is not implemented yet; shared rooms appear in the Shared tab after access is granted." + ? "Invite codes grant access during registration; accepted shared rooms appear in the Shared tab." : "Rooms appear here when ownership, membership, or visibility matches this tab." } /> diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index edcb2a6..4a99f4d 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -88,6 +88,7 @@ async function createFirstAdmin(formData: FormData) { slug: `@${username}`, name: `${username}'s room`, ownerId: createdUser.id, + isPersonal: true, visibility: "FRIENDS" } }); diff --git a/src/components/room-console.tsx b/src/components/room-console.tsx index e303b1e..bbbe7f2 100644 --- a/src/components/room-console.tsx +++ b/src/components/room-console.tsx @@ -1,22 +1,46 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { ChevronDown, ChevronUp, MessageSquareText, Pause, Play, Radio, Settings2, ShieldCheck, SkipForward, Trash2, UserPlus } from "lucide-react"; -import { io } from "socket.io-client"; -import { normalizeMediaUrl } from "@/lib/media"; -import { addMediaToRoom, moveMediaDown, moveMediaUp, removeMediaFromRoom, setCurrentMedia } from "@/lib/media-actions"; -import { addRoomMember, updateRoomSettings } from "@/lib/room-actions"; +import type React from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + ChevronDown, + ChevronUp, + ExternalLink, + MessageSquareText, + Pause, + Play, + Radio, + RotateCcw, + Settings2, + ShieldCheck, + SkipForward, + Trash2, + UserPlus +} from "lucide-react"; +import { io, type Socket } from "socket.io-client"; +import { addRoomMember, deleteRoom, removeRoomMember, resetPersonalRoom, updateRoomSettings } from "@/lib/room-actions"; import { Avatar } from "./avatar"; import { StatusBadge } from "./status-badge"; import { EmptyState, Panel, StatusDot } from "./ui"; -const socket = io({ - path: "/api/socket", - autoConnect: false -}); - declare global { interface Window { + YT?: { + Player: new ( + element: HTMLElement, + options: { + videoId: string; + width: string; + height: string; + playerVars: Record; + events: { + onReady?: () => void; + onError?: () => void; + }; + } + ) => YouTubePlayer; + }; + onYouTubeIframeAPIReady?: () => void; Twitch?: { Player: new ( element: HTMLElement, @@ -33,10 +57,19 @@ declare global { } } +type YouTubePlayer = { + playVideo: () => void; + pauseVideo: () => void; + seekTo: (seconds: number, allowSeekAhead: boolean) => void; + getCurrentTime: () => number; + destroy: () => void; +}; + type TwitchPlayer = { play: () => void; pause: () => void; seek?: (seconds: number) => void; + getCurrentTime?: () => number; destroy?: () => void; }; @@ -51,7 +84,29 @@ type QueueItem = { createdAt: string; }; -type MediaCommand = Pick | null; +type RoomMessage = { + id: string; + body: string; + createdAt: string; + user: string; + avatarUrl?: string | null; +}; + +type PlaybackState = { + mediaSourceId: string; + media: QueueItem; + status: "PLAYING" | "PAUSED"; + position: number; + rate: number; + updatedAt: number; + updatedBy?: string; +}; + +type RoomSnapshot = { + queue: QueueItem[]; + playback: PlaybackState | null; + messages: RoomMessage[]; +}; type Participant = { id: string; @@ -66,6 +121,7 @@ export function RoomConsole({ roomSlug, roomName, roomVisibility, + isPersonal, ownerName, initialRail = "Activity", savedState, @@ -78,6 +134,7 @@ export function RoomConsole({ roomSlug: string; roomName: string; roomVisibility: string; + isPersonal: boolean; ownerName: string; initialRail?: string; savedState?: string; @@ -89,77 +146,151 @@ export function RoomConsole({ const [connected, setConnected] = useState(false); const [source, setSource] = useState(""); const [rail, setRail] = useState(["Activity", "Chat", "Invite", "Settings"].includes(initialRail) ? initialRail : "Activity"); - const [activeQueueItem, setActiveQueueItem] = useState(null); - const iframeRef = useRef(null); + const [queueState, setQueueState] = useState(queue); + const [messages, setMessages] = useState([]); + const [presence, setPresence] = useState(participants); + const [playback, setPlayback] = useState(queue[0] ? initialPlayback(queue[0]) : null); + const [socketError, setSocketError] = useState(""); + const [syncBlocked, setSyncBlocked] = useState(false); + const [playerReady, setPlayerReady] = useState(false); + const [providerIssue, setProviderIssue] = useState(""); + const socketRef = useRef(null); + const youtubePlayerRef = useRef(null); const videoRef = useRef(null); const twitchPlayerRef = useRef(null); - const previewMedia = useMemo(() => (source ? normalizeMediaUrl(source) : null), [source]); - const currentMedia = previewMedia || activeQueueItem || queue[0] || null; + const userActivatedRef = useRef(false); + const lastAppliedRef = useRef(""); - function connect() { - if (!socket.connected) { - socket.connect(); - socket.emit("room:join", { roomSlug, user: currentUser }); - } - setConnected(true); - } + const activeMedia = playback?.media || queueState[0] || null; + const participantRows = useMemo(() => mergePresence(participants, presence), [participants, presence]); + const handlePlayerReady = useCallback(() => setPlayerReady(true), []); + const handleProviderIssue = useCallback((message: string) => setProviderIssue(message), []); - function emit(event: "media:set" | "playback:play" | "playback:pause" | "playback:seek", mediaOverride?: MediaCommand) { - connect(); - const media = mediaOverride || currentMedia; - socket.emit(event, { - provider: media?.provider, - originalUrl: media?.originalUrl, - playbackUrl: media?.playbackUrl, - position: event === "playback:seek" ? 82 : undefined + const emit = useCallback((event: string, payload: Record = {}) => { + setSocketError(""); + socketRef.current?.emit(event, payload); + }, []); + + const currentPosition = useCallback(() => { + if (activeMedia?.provider === "YOUTUBE") return youtubePlayerRef.current?.getCurrentTime?.() || playback?.position || 0; + if (activeMedia?.provider === "TWITCH") return twitchPlayerRef.current?.getCurrentTime?.() || playback?.position || 0; + if (activeMedia?.provider === "DIRECT") return videoRef.current?.currentTime || playback?.position || 0; + return playback?.position || 0; + }, [activeMedia?.provider, playback?.position]); + + const applyPlayback = useCallback( + async (state: PlaybackState | null, force = false) => { + if (!state?.media || !playerReady) return; + const key = `${state.mediaSourceId}:${state.status}:${Math.floor(state.position)}:${state.updatedAt}`; + if (!force && lastAppliedRef.current === key) return; + lastAppliedRef.current = key; + const target = Math.max(0, state.position); + setProviderIssue(""); + if (state.status === "PLAYING" && !userActivatedRef.current) { + setSyncBlocked(true); + return; + } + + try { + if (state.media.provider === "YOUTUBE" && youtubePlayerRef.current) { + youtubePlayerRef.current.seekTo(target, true); + if (state.status === "PLAYING") youtubePlayerRef.current.playVideo(); + if (state.status === "PAUSED") youtubePlayerRef.current.pauseVideo(); + } + + if (state.media.provider === "TWITCH" && twitchPlayerRef.current) { + if (state.status === "PLAYING") twitchPlayerRef.current.play(); + if (state.status === "PAUSED") twitchPlayerRef.current.pause(); + if (isTwitchVod(state.media.playbackUrl) && twitchPlayerRef.current.seek) { + twitchPlayerRef.current.seek(target); + } + } + + if (state.media.provider === "DIRECT" && videoRef.current) { + if (Math.abs(videoRef.current.currentTime - target) > 1.5) videoRef.current.currentTime = target; + if (state.status === "PLAYING") await videoRef.current.play(); + if (state.status === "PAUSED") videoRef.current.pause(); + } + setSyncBlocked(false); + } catch { + setSyncBlocked(state.status === "PLAYING"); + } + }, + [playerReady] + ); + + useEffect(() => { + const socket = io({ path: "/api/socket", autoConnect: false, withCredentials: true }); + socketRef.current = socket; + socket.on("connect", () => { + setConnected(true); + socket.emit("room:join", { roomSlug }); }); - } + socket.on("disconnect", () => setConnected(false)); + socket.on("room:error", ({ message }: { message: string }) => setSocketError(message)); + socket.on("presence:list", (rows: Participant[]) => setPresence(rows)); + socket.on("room:state", (snapshot: RoomSnapshot | null) => { + if (!snapshot) return; + setQueueState(snapshot.queue || []); + setMessages(snapshot.messages || []); + setPlayback(snapshot.playback); + }); + socket.connect(); + return () => { + socket.disconnect(); + socketRef.current = null; + }; + }, [roomSlug]); - function postYoutubeCommand(func: string, args: unknown[] = []) { - iframeRef.current?.contentWindow?.postMessage(JSON.stringify({ event: "command", func, args }), "https://www.youtube.com"); - } + useEffect(() => { + setPlayerReady(false); + setProviderIssue(""); + lastAppliedRef.current = ""; + }, [activeMedia?.id]); - function controlPlayer(action: "play" | "pause" | "seek") { - const provider = currentMedia?.provider; - if (provider === "YOUTUBE") { - if (action === "play") postYoutubeCommand("playVideo"); - if (action === "pause") postYoutubeCommand("pauseVideo"); - if (action === "seek") postYoutubeCommand("seekTo", [82, true]); - } + useEffect(() => { + void applyPlayback(playback); + }, [applyPlayback, playback]); - if (provider === "DIRECT" && videoRef.current) { - if (action === "play") void videoRef.current.play(); - if (action === "pause") videoRef.current.pause(); - if (action === "seek") videoRef.current.currentTime = Math.max(0, videoRef.current.currentTime + 30); - } + useEffect(() => { + const timer = window.setInterval(() => { + if (!playback || playback.status !== "PLAYING" || !activeMedia) return; + const drift = Math.abs(currentPosition() - playback.position); + if (drift > 3) void applyPlayback(playback, true); + }, 5000); + return () => window.clearInterval(timer); + }, [activeMedia, applyPlayback, currentPosition, playback]); - if (provider === "TWITCH" && twitchPlayerRef.current) { - if (action === "play") twitchPlayerRef.current.play(); - if (action === "pause") twitchPlayerRef.current.pause(); - if (action === "seek" && twitchPlayerRef.current.seek) twitchPlayerRef.current.seek(82); - } + function activateSync() { + userActivatedRef.current = true; + setSyncBlocked(false); + void applyPlayback(playback, true); } function handleTransport(event: "playback:play" | "playback:pause" | "playback:seek") { - emit(event); - if (event === "playback:play") controlPlayer("play"); - if (event === "playback:pause") controlPlayer("pause"); - if (event === "playback:seek") controlPlayer("seek"); + userActivatedRef.current = true; + if (!activeMedia) return; + const position = event === "playback:seek" ? currentPosition() + 30 : currentPosition(); + emit(event, { mediaSourceId: activeMedia.id, position }); } - function playQueueItem(item: QueueItem) { - setActiveQueueItem(item); + function addSource(event: React.FormEvent) { + event.preventDefault(); + userActivatedRef.current = true; + const sourceUrl = source.trim(); + if (!sourceUrl) return; + emit("queue:add", { sourceUrl }); setSource(""); - emit("media:set", item); - window.setTimeout(() => { - postYoutubeCommand("playVideo"); - twitchPlayerRef.current?.play(); - void videoRef.current?.play(); - }, 800); } - function submitAndPlay(item: QueueItem) { - playQueueItem(item); + function sendMessage(event: React.FormEvent) { + event.preventDefault(); + const form = event.currentTarget; + const input = form.elements.namedItem("body") as HTMLInputElement | null; + const body = input?.value.trim() || ""; + if (!body) return; + emit("chat:message", { body }); + form.reset(); } return ( @@ -171,21 +302,48 @@ export function RoomConsole({ eyebrow={`/${roomSlug}`} actions={
- + {formatVisibility(roomVisibility)}
} > + {socketError ?

{socketError}

: null}
- {currentMedia ? ( - + {activeMedia ? ( + <> + + {syncBlocked ? ( +
+ Action needed +

Enable sync in this browser

+

Browsers can block remote autoplay until you interact with the page.

+ +
+ ) : null} + {providerIssue ? ( +
+ Provider +

{providerIssue}

+ {activeMedia.provider === "YOUTUBE" ? ( + + Open on YouTube + + ) : null} +
+ ) : null} + ) : (
Idle @@ -195,15 +353,20 @@ export function RoomConsole({ )}
-
- - - - setSource(event.target.value)} placeholder="Paste YouTube, Twitch, or direct video URL" /> -
+ {activeMedia?.provider === "YOUTUBE" ? ( +
+ YouTube may require sign-in in embeds. + + Open on YouTube + +
+ ) : null}
- - {queue.length === 0 ? ( + + {queueState.length === 0 ? ( ) : (
- {queue.map((item, index) => ( -
+ {queueState.map((item, index) => ( +
{index + 1}
{item.thumbnailUrl ? : {item.provider.slice(0, 2)}} @@ -237,30 +408,30 @@ export function RoomConsole({ {item.provider} by {item.by} - {item.createdAt}
-
submitAndPlay(item)}> - - -
-
- - -
-
- - -
-
- - -
+ + + +
))} @@ -268,11 +439,11 @@ export function RoomConsole({ )} - - {participants.length === 0 ? ( + + {participantRows.length === 0 ? ( ) : ( - participants.map((item) => ( + participantRows.map((item) => (
@@ -281,7 +452,7 @@ export function RoomConsole({ {item.role}
- {item.status} + {item.status}
)) )} @@ -301,10 +472,10 @@ export function RoomConsole({ {rail === "Activity" ? (
- {queue.length === 0 ? ( + {queueState.length === 0 ? ( ) : ( - queue.slice(0, 8).map((item) => ( + queueState.slice(0, 8).map((item) => (
@@ -319,11 +490,25 @@ export function RoomConsole({ {rail === "Chat" ? (
- -
) : null} @@ -345,6 +530,28 @@ export function RoomConsole({ Add member + {participants.filter((participant) => participant.role !== "Owner").length > 0 ? ( +
+ {participants + .filter((participant) => participant.role !== "Owner") + .map((participant) => ( +
+ + + {participant.name} + {participant.role} + +
+ + + +
+
+ ))} +
+ ) : null}
) : null} @@ -370,10 +577,28 @@ export function RoomConsole({ Save room settings + + {isPersonal ? ( +
+ + +
+ ) : ( +
+ + +
+ )} } label="Current visibility" value={formatVisibility(roomVisibility)} /> } label="Playback control" value="All authorized participants" /> } label="Owner" value={ownerName} /> - } label="Sync mode" value="Socket.IO room channel" /> + } label="Sync mode" value="Persistent Socket.IO state" />
) : null} @@ -382,54 +607,36 @@ export function RoomConsole({ ); } -function SettingRow({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) { - return ( -
- {icon} - - {label} - {value} - -
- ); -} - function MediaPreview({ provider, playbackUrl, + originalUrl, title, - iframeRef, + youtubePlayerRef, videoRef, - twitchPlayerRef + twitchPlayerRef, + onReady, + onIssue }: { provider: string; playbackUrl: string; + originalUrl: string; title: string; - iframeRef: React.RefObject; + youtubePlayerRef: React.MutableRefObject; videoRef: React.RefObject; twitchPlayerRef: React.MutableRefObject; + onReady: () => void; + onIssue: (message: string) => void; }) { if (provider === "YOUTUBE") { - return ( -