diff --git a/AGENTS.md b/AGENTS.md index 2e3021e..ab2bd24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,9 @@ Reusable monorepo baseline — not a product-specific app. Keep the starter gene ### Shared packages -- `@repo/consts` — string constants: `API_BASE_URL`, endpoint paths (`/api`, `/api/auth/*` including register/login/refresh/me/logout/verify/resend/forgot/reset, `/api/health`, `/api/notes`, `/api/notes/:id`, `/api/movies`, `/api/movies/resolve`, `/api/movies/:id`, `/api/movies/:id/upload`, `/api/movies/:id/stream`), starter copy. Leaf package, no internal deps. -- `@repo/schemas` — Zod 4 schemas and inferred types. Subpaths: `/auth`, `/health`, `/notes`, `/movies`, `/rooms`, `/root`, `/errors` (RFC 7807 `problemDetailsSchema`). Notes exports include `noteIdParamsSchema` for UUID path params. Movies include upload/stream response shapes and relaxed create schema (`name` + `language` required). -- `@repo/contracts` — typed `EndpointContract` objects. Each contract attaches `responseSchema` plus any of `bodySchema`/`paramsSchema`/`querySchema` as real Zod schemas from `@repo/schemas`. Subpaths: `/auth`, `/health`, `/notes`, `/movies`, `/root`. Depends on `@repo/consts`, `@repo/schemas`, `zod`. +- `@repo/consts` — string constants: `API_BASE_URL`, endpoint paths (`/api`, `/api/auth/*` including register/login/refresh/me/logout/verify/resend/forgot/reset, `/api/health`, `/api/notes`, `/api/notes/:id`, `/api/movies`, `/api/movies/resolve`, `/api/movies/:id`, `/api/movies/:id/upload`, `/api/movies/:id/stream`), starter copy, and **`/realtime`** Socket.IO event names (`REALTIME_CLIENT_EVENTS` / `REALTIME_SERVER_EVENTS`) shared by the gateway and frontend client. Leaf package, no internal deps. +- `@repo/schemas` — Zod 4 schemas and inferred types. Subpaths: `/auth`, `/health`, `/notes`, `/movies`, `/rooms`, `/realtime`, `/root`, `/errors` (RFC 7807 `problemDetailsSchema`). Notes exports include `noteIdParamsSchema` for UUID path params. Movies include upload/stream response shapes and relaxed create schema (`name` + `language` required). Realtime exports room-state, chat, playback, join/leave payload schemas (`connectedUserSchema` keyed by `userId` with a `socketIds` array; `roomStatusSchema` re-exported from `/rooms`). +- `@repo/contracts` — typed `EndpointContract` objects. Each contract attaches `responseSchema` plus any of `bodySchema`/`paramsSchema`/`querySchema` as real Zod schemas from `@repo/schemas`. Subpaths: `/auth`, `/health`, `/notes`, `/movies`, `/root`, and **`/realtime`** (`SocketEventContract` tying each event name to its payload schema + direction). Depends on `@repo/consts`, `@repo/schemas`, `zod`. - `@repo/ui` — React components (button, card, code). Not currently consumed by any app. - `@repo/eslint-config` — ESLint 9 flat configs: `base`, `node`, `react-internal`, `next-js`. - `@repo/typescript-config` — TS configs: `base.json`, `node.json`, `nextjs.json`, `vite.json`, `react-library.json`. diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 9de94ba..5245013 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -21,7 +21,14 @@ NestJS 11 API starter. Should stay framework-clean and easy to extend into a rea - `src/movies/` — movies domain (Mongo metadata + object storage via `src/storage/`); **`JwtAuthGuard`** on all routes. `POST /api/movies/resolve` idempotently returns an owned movie by name (200) or creates one (201). `POST /api/movies/:id/upload` (multipart MP4), `GET /api/movies/:id/stream` (presigned URL). Unique index on `{ ownerId, name }` where `deleted_at: null`; **`MovieIndexSyncService`** drops legacy global `name_1` on startup. Files purge from storage ~24h after linked room deactivates; metadata retained. **`MovieCleanupService`** cron hourly. - `src/storage/` — **Cloudflare R2** via S3-compatible API (`S3StorageService`) or in-memory (`InMemoryStorageService`). R2 buckets: `uniwatch-dev` (development) and `uniwatch-production` (production), configured in gitignored `.env.development` / `.env.production` (`S3_*` vars). Defaults to in-memory in development/test unless `STORAGE_DRIVER=s3`; production uses S3/R2 automatically. Override with `STORAGE_DRIVER=memory|s3`. **Troubleshooting `EPROTO` / SSL alert 40:** `S3_ENDPOINT` must be `https://.r2.cloudflarestorage.com` where `` is from **R2 → Overview** (not the API token access key). Verify with `openssl s_client -connect .r2.cloudflarestorage.com:443 -servername .r2.cloudflarestorage.com` — handshake must succeed. EU: use `.eu.r2.cloudflarestorage.com`. Set `S3_FORCE_PATH_STYLE=true`. - `src/rooms/` — rooms domain (Mongo + contracts); **`JwtAuthGuard`** on the controller. **`POST /api/rooms`** sets **`creator` from the JWT** (never from the client body). **List/get** return rooms where the user is **`creator` or in `allowed_users`**; **delete** is **creator-only** (others get **403**). Creating a room requires a **`movie` id owned by the same user** (validated via `MoviesService`). -- `src/realtime/` — `RealtimeModule` + `RealtimeGateway`: Socket.IO demo handler (`ping` → `pong` event). +- `src/realtime/` — `RealtimeModule` + `RealtimeGateway`: authenticated Socket.IO room lifecycle, chat, movie updates, playback sync, and per-user multi-socket tracking. The gateway is a **thin transport layer** — it resolves the actor via **`WsAuthGuard`** (`getAuthenticatedUser(socket)` reads `socket.data.user`), validates payloads via **`ZodWsValidationPipe`** (schemas from `@repo/schemas/realtime`), maps thrown `WsException`s to `room:error` via **`WsExceptionFilter`**, and delegates to focused services: + - `services/connection-registry.service.ts` — bidirectional index: `socketId → user` and `userId → Set` for multi-tab/multi-device. Methods `register` / `unregister` / `getUser` / `getSocketIds`. + - `services/room-state.service.ts` — in-memory per-room runtime state (connected users keyed by `userId` with a `socketIds` array — no redundant single `socketId`, chat history, playback, countdown, status). + - `services/playback-countdown.service.ts` — pre-playback countdown timers + queued start; emits side effects via an `onComplete` callback so it stays socket-free. + - `services/room-moderation.service.ts` — kick/block: asserts the actor is the room creator, optionally bans, and disconnects every live socket the target holds. + - `services/realtime-broadcast.service.ts` — the single owner of every outbound socket write; wraps the Socket.IO `Server` and implements `RealtimeBroadcastPort`. + - `services/socket-auth.service.ts` — resolves `SocketUserInfo` from the access-token cookie during `handleConnection`. + Event names are the shared constants in `@repo/consts/realtime` (`REALTIME_CLIENT_EVENTS` / `REALTIME_SERVER_EVENTS`) with a typed `SocketEventContract` layer in `@repo/contracts/realtime`. The HTTP layer (`RoomsService`) depends on the **`REALTIME_BROADCAST_PORT`** abstraction (`realtime.broadcast-port.ts`), not the concrete gateway, keeping the Rooms↔Realtime cycle decoupled. - `src/filters/` — `HttpExceptionFilter` that catches all exceptions and responds with RFC 7807 `ProblemDetails` from `@repo/schemas/errors`. Each response carries a stable `type` URI (`/problems/validation-failed` | `/problems/internal-error` | `/problems/http-error`) and a `traceId` from `RequestIdMiddleware`. Production-aware: redacts unexpected-error detail, redacts 5xx `HttpException` detail, **forces 5xx `HttpException` title to the canonical `STATUS_TITLES` entry** (prevents custom-error-name leaks via `throw new HttpException({ error: 'Postgres: secret_xyz' }, 500)`), strips Zod validation issues, strips the `errors` array from `HttpException` responses, and strips the query string from `instance`. Dev keeps all detail + issues + custom titles. The server log always has the full truth tagged with `[trace=]` — use the id a user reports to find the matching log line. `response.headersSent` is checked before writing, and `safeStringify` caps output at 2 KB. - `src/middleware/request-id.middleware.ts` — per-request correlation id, read from `x-request-id` (validated) or generated via `randomUUID()`. Echoed back as `x-request-id` on the response. Wired in `AppModule.configure()`. diff --git a/apps/backend/CLAUDE.md b/apps/backend/CLAUDE.md index 7878a97..fe43577 100644 --- a/apps/backend/CLAUDE.md +++ b/apps/backend/CLAUDE.md @@ -46,6 +46,19 @@ src/ auth.dto.ts — nestjs-zod DTOs wrapping `@repo/schemas/auth` auth.consts.ts — cookie names for access / refresh tokens auth.types.ts — JwtAccessPayload + global `Express.Request` merge (`authPayload`) + realtime/ + realtime.module.ts — RealtimeModule (provides REALTIME_BROADCAST_PORT via RealtimeBroadcastService) + realtime.gateway.ts — thin Socket.IO transport: WsAuthGuard + ZodWsValidationPipe + WsExceptionFilter, delegates to the services below. Event names from `@repo/consts/realtime`. + realtime.broadcast-port.ts — RealtimeBroadcastPort interface + REALTIME_BROADCAST_PORT token (RoomsService depends on this, not the gateway) + ws-auth.guard.ts — resolves SocketUserInfo onto socket.data.user; getAuthenticatedUser(socket) helper + zod-ws-validation.pipe.ts — validates @MessageBody() against a Zod schema, throws WsException on failure + ws-exception.filter.ts — maps WsException → `room:error` emit + services/connection-registry.service.ts — socketId↔user + userId→Set index (multi-socket) + services/room-state.service.ts — in-memory per-room state (members keyed by userId with socketIds[], chat, playback, countdown, status) + services/playback-countdown.service.ts — countdown timers + queued start, side effects via onComplete callback + services/room-moderation.service.ts — kick/block: creator check, optional ban, disconnect all target sockets + services/realtime-broadcast.service.ts — single owner of outbound socket writes; implements RealtimeBroadcastPort + services/socket-auth.service.ts — resolves the user from the access-token cookie on connect test/ app.e2e-spec.ts — supertest e2e suite: verifies /api prefix, x-request-id echo + UUID fallback + unsafe-id rejection, ProblemDetails shape + traceId propagation, Swagger served at /docs, auth register/login/refresh/me/logout jest-e2e.setup.ts — sets JWT_* env defaults so e2e can boot without a real `.env` diff --git a/apps/backend/src/movies/movies.service.ts b/apps/backend/src/movies/movies.service.ts index 4d13207..ff7ad45 100644 --- a/apps/backend/src/movies/movies.service.ts +++ b/apps/backend/src/movies/movies.service.ts @@ -192,15 +192,10 @@ export class MoviesService { const storageKey = `movies/${ownerId}/${id}/${randomUUID()}${extensionForMovieMime(resolvedMime)}`; const thumbnailKey = `movies/${ownerId}/${id}/${randomUUID()}.svg`; + const previousStorageKey = doc.storage_key; + const previousThumbnailKey = doc.thumbnail_key; try { - if (doc.storage_key != null) { - await this.storage.deleteObject(doc.storage_key).catch(() => undefined); - } - if (doc.thumbnail_key != null) { - await this.storage.deleteObject(doc.thumbnail_key).catch(() => undefined); - } - const stream = createReadStream(file.path); await this.storage.putObject({ key: storageKey, @@ -228,8 +223,18 @@ export class MoviesService { file_purge_at: null }); if (!updated) { + await this.storage.deleteObject(storageKey).catch(() => undefined); + await this.storage.deleteObject(thumbnailKey).catch(() => undefined); throw new NotFoundException(`Movie "${id}" not found`); } + + if (previousStorageKey != null && previousStorageKey !== storageKey) { + await this.storage.deleteObject(previousStorageKey).catch(() => undefined); + } + if (previousThumbnailKey != null && previousThumbnailKey !== thumbnailKey) { + await this.storage.deleteObject(previousThumbnailKey).catch(() => undefined); + } + return toResponse(updated); } catch (error) { await this.movies.update(id, ownerId, { upload_status: MovieUploadStatus.FAILED }); @@ -356,12 +361,31 @@ export class MoviesService { private async userHasRoomAccessToMovie(movieId: string, userId: string): Promise { const uid = new Types.ObjectId(userId); - const room = await this.roomModel.findOne({ - movie: new Types.ObjectId(movieId), + const movieOid = new Types.ObjectId(movieId); + + const roomWithMovie = await this.roomModel.findOne({ + movie: movieOid, + deleted_at: null, + $or: [{ creator: uid }, { allowed_users: uid }] + }); + if (roomWithMovie != null) { + return true; + } + + // During a mid-session swap, socket playback can reference the new movie id + // before every reader sees the updated room.movie field. Room members may + // stream any movie owned by their room host while in that session. + const movie = await this.movies.findById(movieId); + if (!movie || movie.deleted_at) { + return false; + } + + const hostRoom = await this.roomModel.findOne({ + creator: movie.ownerId, deleted_at: null, $or: [{ creator: uid }, { allowed_users: uid }] }); - return room != null; + return hostRoom != null; } private async assertExistsAndOwnedOrThrow(id: string): Promise { diff --git a/apps/backend/src/realtime/realtime.broadcast-port.ts b/apps/backend/src/realtime/realtime.broadcast-port.ts new file mode 100644 index 0000000..07efee4 --- /dev/null +++ b/apps/backend/src/realtime/realtime.broadcast-port.ts @@ -0,0 +1,13 @@ +/** + * Narrow port the HTTP layer (RoomsService) depends on to push realtime + * side effects, without coupling to the concrete gateway implementation. + */ +export interface RealtimeBroadcastPort { + clearCountdown(roomId: string): void; + emitRoomMovieUpdated(roomId: string, movieId: string, movieName?: string): void; + emitRoomPlaybackChanged(roomId: string, actorUserId: string | null): void; + emitRoomState(roomId: string): void; + removeRoomMember(roomId: string, userId: string): Promise; +} + +export const REALTIME_BROADCAST_PORT = Symbol('REALTIME_BROADCAST_PORT'); diff --git a/apps/backend/src/realtime/realtime.gateway.spec.ts b/apps/backend/src/realtime/realtime.gateway.spec.ts new file mode 100644 index 0000000..e808379 --- /dev/null +++ b/apps/backend/src/realtime/realtime.gateway.spec.ts @@ -0,0 +1,741 @@ +import { Types } from 'mongoose'; +import type { Server } from 'socket.io'; +import { REALTIME_SERVER_EVENTS } from '@repo/consts/realtime'; +import type { PlaybackState } from '@repo/schemas/realtime'; + +import { RealtimeGateway } from '@/realtime/realtime.gateway'; +import { ConnectionRegistryService } from '@/realtime/services/connection-registry.service'; +import { PlaybackCountdownService } from '@/realtime/services/playback-countdown.service'; +import { RealtimeBroadcastService } from '@/realtime/services/realtime-broadcast.service'; +import { RoomModerationService } from '@/realtime/services/room-moderation.service'; +import { RoomMovieChangeService } from '@/realtime/services/room-movie-change.service'; +import { RoomStateService } from '@/realtime/services/room-state.service'; +import type { SocketAuthService } from '@/realtime/services/socket-auth.service'; +import type { RoomRepository } from '@/rooms/room.repository'; + +type ActorSocket = { + id: string; + rooms: Set; + emit: jest.Mock; + disconnect: jest.Mock; + data: { user: { userId: string; userName: string } }; +}; + +describe('RealtimeGateway', () => { + const roomId = new Types.ObjectId().toString(); + const hostId = new Types.ObjectId().toString(); + const initialMovieId = new Types.ObjectId().toString(); + const nextMovieId = new Types.ObjectId().toString(); + + let gateway: RealtimeGateway; + let roomState: RoomStateService; + let registry: ConnectionRegistryService; + let countdown: PlaybackCountdownService; + let broadcast: RealtimeBroadcastService; + let movieChange: RoomMovieChangeService; + let moderation: RoomModerationService; + let rooms: jest.Mocked< + Pick + >; + let roomEmit: jest.Mock; + let socketRegistry: Map; + let socket: ActorSocket; + + function actorSocket(id: string, userId: string, userName: string): ActorSocket { + return { + id, + rooms: new Set([roomId]), + emit: jest.fn(), + disconnect: jest.fn(), + data: { user: { userId, userName } } + }; + } + + beforeEach(() => { + jest.useFakeTimers({ now: Date.parse('2026-06-16T12:00:00.000Z') }); + + roomState = new RoomStateService(); + registry = new ConnectionRegistryService(); + countdown = new PlaybackCountdownService(roomState); + rooms = { + findOneAccessibleById: jest.fn(), + setStatus: jest.fn().mockResolvedValue(null), + banUser: jest.fn(), + findRawById: jest.fn() + }; + broadcast = new RealtimeBroadcastService(roomState, countdown, rooms as unknown as RoomRepository); + movieChange = new RoomMovieChangeService(roomState, countdown, broadcast, rooms as unknown as RoomRepository); + moderation = new RoomModerationService(rooms as unknown as RoomRepository, roomState, broadcast); + gateway = new RealtimeGateway( + rooms as unknown as RoomRepository, + roomState, + {} as SocketAuthService, + registry, + countdown, + broadcast, + moderation, + movieChange + ); + + roomEmit = jest.fn(); + socketRegistry = new Map(); + broadcast.bind({ + to: jest.fn(() => ({ emit: roomEmit })), + sockets: { sockets: socketRegistry } + } as unknown as Server); + + registry.register('socket-1', { userId: hostId, userName: 'Host' }); + roomState.joinUser({ roomId, userId: hostId, userName: 'Host', socketId: 'socket-1' }); + roomState.setUserReady(roomId, hostId, true); + + socket = actorSocket('socket-1', hostId, 'Host'); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + function mockHostRoom(movieId: string, movieName = 'Room movie'): void { + rooms.findOneAccessibleById.mockResolvedValue({ + _id: new Types.ObjectId(roomId), + creator: new Types.ObjectId(hostId), + movie: new Types.ObjectId(movieId), + movie_name: movieName + } as never); + } + + function roomEmitPlaybackPayload(eventName: string): { playback: PlaybackState } { + for (const entry of roomEmit.mock.calls) { + const [event, payload] = entry as [string, { playback: PlaybackState }]; + if (event === eventName) { + return payload; + } + } + throw new Error(`Expected room emit for ${eventName}`); + } + + function roomEmitHasEvent(eventName: string): boolean { + return roomEmit.mock.calls.some(([event]) => event === eventName); + } + + it('sets the room to waiting when the last member disconnects during playback', async () => { + mockHostRoom(initialMovieId); + roomState.syncMovie(roomId, initialMovieId); + roomState.updatePlayback(roomId, { + movieId: initialMovieId, + isPlaying: true, + positionSec: 120, + playbackRate: 1 + }); + + gateway.handleDisconnect({ + id: 'socket-1', + rooms: new Set([roomId]) + } as never); + + await Promise.resolve(); + + expect(roomState.get(roomId)).toBeUndefined(); + expect(rooms.setStatus).toHaveBeenCalledWith(roomId, 'waiting'); + }); + + it('keeps the room member until the last socket for that user disconnects', () => { + registry.register('socket-2', { userId: hostId, userName: 'Host' }); + roomState.joinUser({ roomId, userId: hostId, userName: 'Host', socketId: 'socket-2' }); + + gateway.handleDisconnect({ + id: 'socket-1', + rooms: new Set([roomId]) + } as never); + + const afterFirst = roomState.get(roomId); + expect(afterFirst?.connectedUsers).toHaveLength(1); + expect(afterFirst?.connectedUsers[0]?.socketIds).toEqual(['socket-2']); + expect(roomEmit).not.toHaveBeenCalledWith('room:user-left', expect.anything()); + + gateway.handleDisconnect({ + id: 'socket-2', + rooms: new Set([roomId]) + } as never); + + expect(roomState.get(roomId)).toBeUndefined(); + expect(roomEmit).toHaveBeenCalledWith('room:user-left', { userId: hostId, roomId }); + }); + + it('disconnects every live socket for a moderated user in the room', async () => { + const viewerId = new Types.ObjectId().toString(); + const viewerSocketTwo = { emit: jest.fn(), disconnect: jest.fn() }; + const viewerSocketThree = { emit: jest.fn(), disconnect: jest.fn() }; + mockHostRoom(initialMovieId); + + registry.register('socket-2', { userId: viewerId, userName: 'Viewer' }); + registry.register('socket-3', { userId: viewerId, userName: 'Viewer' }); + socketRegistry.set('socket-2', viewerSocketTwo); + socketRegistry.set('socket-3', viewerSocketThree); + roomState.joinUser({ roomId, userId: viewerId, userName: 'Viewer', socketId: 'socket-2' }); + roomState.joinUser({ roomId, userId: viewerId, userName: 'Viewer', socketId: 'socket-3' }); + + await gateway.handleKickUser(socket as never, { roomId, targetUserId: viewerId }); + + expect(viewerSocketTwo.emit).toHaveBeenCalledWith('room:error', { message: 'kicked from the room' }); + expect(viewerSocketThree.emit).toHaveBeenCalledWith('room:error', { message: 'kicked from the room' }); + expect(viewerSocketTwo.disconnect).toHaveBeenCalledWith(true); + expect(viewerSocketThree.disconnect).toHaveBeenCalledWith(true); + }); + + it('bans a user before disconnecting their sockets when blocking', async () => { + const viewerId = new Types.ObjectId().toString(); + const viewerSocket = { emit: jest.fn(), disconnect: jest.fn() }; + mockHostRoom(initialMovieId); + + registry.register('socket-2', { userId: viewerId, userName: 'Viewer' }); + socketRegistry.set('socket-2', viewerSocket); + roomState.joinUser({ roomId, userId: viewerId, userName: 'Viewer', socketId: 'socket-2' }); + + await gateway.handleBlockUser(socket as never, { roomId, targetUserId: viewerId }); + + expect((rooms.banUser as jest.Mock).mock.calls).toHaveLength(1); + expect(viewerSocket.emit).toHaveBeenCalledWith('room:error', { message: 'blocked from the room' }); + expect(viewerSocket.disconnect).toHaveBeenCalledWith(true); + }); + + it('broadcasts a chat message to the room', () => { + gateway.handleMessage(socket as never, { roomId, content: 'hello' }); + + expect(roomEmit).toHaveBeenCalledWith( + 'room:message-received', + expect.objectContaining({ roomId, userId: hostId, content: 'hello' }) + ); + expect(roomState.get(roomId)?.messages).toHaveLength(1); + }); + + it('rejects a message from a socket that is not in the room', () => { + const outsider = actorSocket('socket-1', hostId, 'Host'); + outsider.rooms = new Set(); + + expect(() => { + gateway.handleMessage(outsider as never, { roomId, content: 'hi' }); + }).toThrow('Forbidden'); + }); + + it('adds the joining user and announces them to the room', async () => { + const viewerId = new Types.ObjectId().toString(); + mockHostRoom(initialMovieId); + registry.register('socket-2', { userId: viewerId, userName: 'Viewer' }); + const viewer = actorSocket('socket-2', viewerId, 'Viewer'); + viewer.rooms = new Set(); + const joinFn = jest.fn((): Promise => { + viewer.rooms.add(roomId); + return Promise.resolve(); + }); + (viewer as unknown as { join: jest.Mock }).join = joinFn; + + await gateway.handleJoin(viewer as never, { roomId }); + + expect(joinFn).toHaveBeenCalledWith(roomId); + expect(viewer.emit).toHaveBeenCalledWith('room:state', expect.objectContaining({ status: 'waiting' })); + expect(roomEmit).toHaveBeenCalledWith( + 'room:user-joined', + expect.objectContaining({ userId: viewerId, roomId }) + ); + expect(roomEmit).not.toHaveBeenCalledWith('room:state', expect.anything()); + }); + + it('queues playback until the countdown ends', async () => { + mockHostRoom(initialMovieId); + roomState.syncMovie(roomId, initialMovieId); + + await gateway.handlePlaybackUpdate(socket as never, { + roomId, + movieId: initialMovieId, + isPlaying: true, + positionSec: 0, + playbackRate: 1 + }); + + const firstCountdown = roomState.get(roomId)?.countdown; + expect(firstCountdown?.active).toBe(true); + expect(firstCountdown?.endsAt).toBe('2026-06-16T12:00:03.000Z'); + expect(roomState.get(roomId)?.playback.isPlaying).toBe(false); + + jest.advanceTimersByTime(1000); + + await gateway.handlePlaybackUpdate(socket as never, { + roomId, + movieId: initialMovieId, + isPlaying: true, + positionSec: 12, + playbackRate: 1 + }); + + expect(roomState.get(roomId)?.countdown.endsAt).toBe(firstCountdown?.endsAt ?? null); + expect(roomState.get(roomId)?.playback.isPlaying).toBe(false); + + jest.advanceTimersByTime(2000); + + const livePlayback = roomState.get(roomId)?.playback; + expect(roomState.get(roomId)?.countdown.active).toBe(false); + expect(livePlayback?.isPlaying).toBe(true); + expect(livePlayback?.positionSec).toBe(12); + expect(livePlayback?.updatedAt).toBe('2026-06-16T12:00:03.000Z'); + expect(roomEmit).toHaveBeenCalledWith('room:playback-changed', expect.any(Object)); + expect(roomEmit).toHaveBeenCalledWith( + 'room:presence-changed', + expect.objectContaining({ + countdown: { active: false, endsAt: null } + }) + ); + expect(roomEmitHasEvent(REALTIME_SERVER_EVENTS.roomState)).toBe(false); + }); + + it('clears the countdown when playback pauses', async () => { + mockHostRoom(initialMovieId); + roomState.syncMovie(roomId, initialMovieId); + + await gateway.handlePlaybackUpdate(socket as never, { + roomId, + movieId: initialMovieId, + isPlaying: true, + positionSec: 0, + playbackRate: 1 + }); + expect(roomState.get(roomId)?.countdown.active).toBe(true); + + await gateway.handlePlaybackUpdate(socket as never, { + roomId, + movieId: initialMovieId, + isPlaying: false, + positionSec: 12, + playbackRate: 1 + }); + + expect(roomState.get(roomId)?.countdown.active).toBe(false); + expect(roomState.get(roomId)?.countdown.endsAt).toBeNull(); + }); + + it('pauses at zero and resets readiness when the movie changes during playback', async () => { + const viewerId = new Types.ObjectId().toString(); + mockHostRoom(initialMovieId); + roomState.syncMovie(roomId, initialMovieId); + roomState.joinUser({ roomId, userId: viewerId, userName: 'Viewer', socketId: 'socket-2' }); + roomState.setUserReady(roomId, hostId, true); + roomState.setUserReady(roomId, viewerId, true); + roomState.updatePlayback(roomId, { + movieId: initialMovieId, + isPlaying: true, + positionSec: 90, + playbackRate: 1 + }); + + rooms.findOneAccessibleById.mockResolvedValueOnce({ + _id: new Types.ObjectId(roomId), + creator: new Types.ObjectId(hostId), + movie: new Types.ObjectId(nextMovieId), + movie_name: 'Next movie' + } as never); + + roomEmit.mockClear(); + + await gateway.handleMovieUpdated(socket as never, { roomId, movieId: nextMovieId }); + + const playback = roomState.get(roomId)?.playback; + expect(playback?.movieId).toBe(nextMovieId); + expect(playback?.isPlaying).toBe(false); + expect(playback?.positionSec).toBe(0); + expect(roomState.get(roomId)?.connectedUsers.every((user) => !user.isReady)).toBe(true); + expect(roomState.syncStatus(roomId, hostId)).toBe('waiting'); + expect(roomEmit).toHaveBeenCalledWith('room:movie-updated', expect.objectContaining({ movieId: nextMovieId })); + expect(roomEmit).toHaveBeenCalledWith('room:playback-changed', expect.any(Object)); + expect(roomEmit).toHaveBeenCalledWith('room:presence-changed', expect.any(Object)); + expect(roomEmitHasEvent('room:message-received')).toBe(true); + const messages = roomState.get(roomId)?.messages ?? []; + expect(messages.at(-1)?.content).toContain('Next movie'); + expect(roomState.get(roomId)?.countdown.active).toBe(false); + }); + + it('starts playback after swap when host plays and countdown completes', async () => { + const viewerId = new Types.ObjectId().toString(); + mockHostRoom(initialMovieId); + roomState.syncMovie(roomId, initialMovieId); + roomState.joinUser({ roomId, userId: viewerId, userName: 'Viewer', socketId: 'socket-2' }); + roomState.setUserReady(roomId, hostId, true); + roomState.setUserReady(roomId, viewerId, true); + roomState.updatePlayback(roomId, { + movieId: initialMovieId, + isPlaying: true, + positionSec: 90, + playbackRate: 1 + }); + + rooms.findOneAccessibleById.mockResolvedValueOnce({ + _id: new Types.ObjectId(roomId), + creator: new Types.ObjectId(hostId), + movie: new Types.ObjectId(nextMovieId), + movie_name: 'Next movie' + } as never); + + await gateway.handleMovieUpdated(socket as never, { roomId, movieId: nextMovieId }); + + expect(roomState.get(roomId)?.countdown.active).toBe(false); + expect(roomState.get(roomId)?.playback.isPlaying).toBe(false); + + roomState.setUserReady(roomId, hostId, true); + roomState.setUserReady(roomId, viewerId, true); + mockHostRoom(nextMovieId, 'Next movie'); + + roomEmit.mockClear(); + + await gateway.handlePlaybackUpdate(socket as never, { + roomId, + movieId: nextMovieId, + isPlaying: true, + positionSec: 0, + playbackRate: 1 + }); + + expect(roomState.get(roomId)?.countdown.active).toBe(true); + + jest.advanceTimersByTime(3000); + + const startedPlayback = roomState.get(roomId)?.playback; + expect(roomState.get(roomId)?.countdown.active).toBe(false); + expect(startedPlayback?.movieId).toBe(nextMovieId); + expect(startedPlayback?.isPlaying).toBe(true); + + expect(roomEmitHasEvent(REALTIME_SERVER_EVENTS.playbackChanged)).toBe(true); + const playbackPayload = roomEmitPlaybackPayload(REALTIME_SERVER_EVENTS.playbackChanged); + expect(playbackPayload.playback.isPlaying).toBe(true); + expect(playbackPayload.playback.movieId).toBe(nextMovieId); + }); + + it('accepts host play for runtime movie id when persisted room.movie is stale', async () => { + mockHostRoom(initialMovieId); + roomState.syncMovie(roomId, nextMovieId); + roomState.updatePlayback(roomId, { + movieId: nextMovieId, + isPlaying: false, + positionSec: 0, + playbackRate: 1 + }); + roomState.setUserReady(roomId, hostId, true); + + rooms.findOneAccessibleById.mockResolvedValue({ + _id: new Types.ObjectId(roomId), + creator: new Types.ObjectId(hostId), + movie: new Types.ObjectId(initialMovieId), + movie_name: 'Old movie' + } as never); + + await gateway.handlePlaybackUpdate(socket as never, { + roomId, + movieId: nextMovieId, + isPlaying: true, + positionSec: 0, + playbackRate: 1 + }); + + expect(roomState.get(roomId)?.countdown.active).toBe(true); + expect(roomState.get(roomId)?.playback.movieId).toBe(nextMovieId); + }); + + it('keeps a queued start aligned when the movie changes before the countdown ends', async () => { + mockHostRoom(initialMovieId); + roomState.syncMovie(roomId, initialMovieId); + + await gateway.handlePlaybackUpdate(socket as never, { + roomId, + movieId: initialMovieId, + isPlaying: true, + positionSec: 0, + playbackRate: 1 + }); + expect(roomState.get(roomId)?.countdown.active).toBe(true); + + rooms.findOneAccessibleById.mockResolvedValueOnce({ + _id: new Types.ObjectId(roomId), + creator: new Types.ObjectId(hostId), + movie: new Types.ObjectId(nextMovieId), + movie_name: 'Next movie' + } as never); + + await gateway.handleMovieUpdated(socket as never, { roomId, movieId: nextMovieId }); + + const playback = roomState.get(roomId)?.playback; + expect(playback?.movieId).toBe(nextMovieId); + expect(playback?.isPlaying).toBe(false); + expect(playback?.positionSec).toBe(0); + + jest.advanceTimersByTime(3000); + + const startedPlayback = roomState.get(roomId)?.playback; + expect(roomState.get(roomId)?.countdown.active).toBe(false); + expect(startedPlayback?.movieId).toBe(nextMovieId); + expect(startedPlayback?.isPlaying).toBe(true); + expect(startedPlayback?.updatedAt).toBe('2026-06-16T12:00:03.000Z'); + }); + + it('expires the countdown after three seconds', async () => { + mockHostRoom(initialMovieId); + roomState.syncMovie(roomId, initialMovieId); + + await gateway.handlePlaybackUpdate(socket as never, { + roomId, + movieId: initialMovieId, + isPlaying: true, + positionSec: 0, + playbackRate: 1 + }); + expect(roomState.get(roomId)?.countdown.active).toBe(true); + + jest.advanceTimersByTime(3000); + + expect(roomState.get(roomId)?.countdown.active).toBe(false); + expect(roomState.get(roomId)?.countdown.endsAt).toBeNull(); + expect(roomEmit).toHaveBeenCalled(); + }); + + it('rejects playback when not all connected users are ready', async () => { + mockHostRoom(initialMovieId); + roomState.syncMovie(roomId, initialMovieId); + roomState.joinUser({ + roomId, + userId: new Types.ObjectId().toString(), + userName: 'Viewer', + socketId: 'socket-2' + }); + roomState.setUserReady(roomId, hostId, true); + + await expect( + gateway.handlePlaybackUpdate(socket as never, { + roomId, + movieId: initialMovieId, + isPlaying: true, + positionSec: 0, + playbackRate: 1 + }) + ).rejects.toThrow('All users must be ready before playback starts.'); + + expect(roomState.get(roomId)?.countdown.active).toBe(false); + expect(roomState.get(roomId)?.playback.isPlaying).toBe(false); + }); + + it('allows the solo host to start playback even when unready', async () => { + mockHostRoom(initialMovieId); + roomState.syncMovie(roomId, initialMovieId); + roomState.setUserReady(roomId, hostId, false); + + await gateway.handlePlaybackUpdate(socket as never, { + roomId, + movieId: initialMovieId, + isPlaying: true, + positionSec: 0, + playbackRate: 1 + }); + + expect(roomState.get(roomId)?.countdown.active).toBe(true); + jest.advanceTimersByTime(3000); + expect(roomState.get(roomId)?.playback.isPlaying).toBe(true); + }); + + it('accepts a force play request when viewers are still unready', async () => { + mockHostRoom(initialMovieId); + roomState.syncMovie(roomId, initialMovieId); + roomState.joinUser({ + roomId, + userId: new Types.ObjectId().toString(), + userName: 'Viewer', + socketId: 'socket-2' + }); + + await gateway.handlePlaybackUpdate(socket as never, { + roomId, + movieId: initialMovieId, + isPlaying: true, + positionSec: 0, + playbackRate: 1, + force: true + }); + + expect(roomState.get(roomId)?.countdown.active).toBe(true); + jest.advanceTimersByTime(3000); + expect(roomState.get(roomId)?.playback.isPlaying).toBe(true); + }); + + it('does not broadcast position ticks while playback is already running', async () => { + mockHostRoom(initialMovieId); + roomState.syncMovie(roomId, initialMovieId); + roomState.updatePlayback(roomId, { + movieId: initialMovieId, + isPlaying: true, + positionSec: 60, + playbackRate: 1 + }); + + roomEmit.mockClear(); + + await gateway.handlePlaybackUpdate(socket as never, { + roomId, + movieId: initialMovieId, + isPlaying: true, + positionSec: 61, + playbackRate: 1 + }); + + expect(roomState.get(roomId)?.playback.positionSec).toBe(61); + expect(roomEmit).not.toHaveBeenCalledWith('room:playback-changed', expect.any(Object)); + expect(roomEmitHasEvent(REALTIME_SERVER_EVENTS.roomState)).toBe(false); + }); + + it('broadcasts seek and rate changes during active playback', async () => { + mockHostRoom(initialMovieId); + roomState.syncMovie(roomId, initialMovieId); + roomState.updatePlayback(roomId, { + movieId: initialMovieId, + isPlaying: true, + positionSec: 60, + playbackRate: 1 + }); + + roomEmit.mockClear(); + + await gateway.handlePlaybackUpdate(socket as never, { + roomId, + movieId: initialMovieId, + isPlaying: true, + positionSec: 120, + playbackRate: 1, + force: true + }); + + expect(roomEmit).toHaveBeenCalledWith('room:playback-changed', expect.any(Object)); + expect(roomEmitHasEvent(REALTIME_SERVER_EVENTS.roomState)).toBe(false); + + roomEmit.mockClear(); + + await gateway.handlePlaybackUpdate(socket as never, { + roomId, + movieId: initialMovieId, + isPlaying: true, + positionSec: 120, + playbackRate: 1.5 + }); + + expect(roomEmit).toHaveBeenCalledWith('room:playback-changed', expect.any(Object)); + }); + + it('keeps playback running when a viewer leaves during an active watch', async () => { + const viewerId = new Types.ObjectId().toString(); + mockHostRoom(initialMovieId); + roomState.syncMovie(roomId, initialMovieId); + roomState.joinUser({ roomId, userId: viewerId, userName: 'Viewer', socketId: 'socket-2' }); + roomState.updatePlayback(roomId, { + movieId: initialMovieId, + isPlaying: true, + positionSec: 120, + playbackRate: 1 + }); + roomState.syncStatus(roomId, hostId); + rooms.findRawById.mockResolvedValue({ + _id: new Types.ObjectId(roomId), + creator: new Types.ObjectId(hostId) + } as never); + + const viewerSocket = actorSocket('socket-2', viewerId, 'Viewer'); + (viewerSocket as unknown as { leave: jest.Mock }).leave = jest.fn().mockResolvedValue(undefined); + await gateway.handleLeave(viewerSocket as never, { roomId }); + + const playback = roomState.get(roomId)?.playback; + expect(playback?.isPlaying).toBe(true); + expect(playback?.movieId).toBe(initialMovieId); + expect(roomState.syncStatus(roomId, hostId)).toBe('watching'); + expect(roomEmit).toHaveBeenCalledWith('room:presence-changed', expect.any(Object)); + expect(roomEmitHasEvent(REALTIME_SERVER_EVENTS.roomState)).toBe(false); + }); + + it('resets the playback session when the host reports the movie ended', async () => { + const viewerId = new Types.ObjectId().toString(); + mockHostRoom(initialMovieId); + roomState.syncMovie(roomId, initialMovieId); + roomState.joinUser({ roomId, userId: viewerId, userName: 'Viewer', socketId: 'socket-2' }); + roomState.setUserReady(roomId, hostId, true); + roomState.setUserReady(roomId, viewerId, true); + roomState.updatePlayback(roomId, { + movieId: initialMovieId, + isPlaying: true, + positionSec: 5400, + playbackRate: 1 + }); + roomState.syncStatus(roomId, hostId); + + await gateway.handlePlaybackUpdate(socket as never, { + roomId, + movieId: initialMovieId, + isPlaying: false, + positionSec: 5400, + playbackRate: 1, + ended: true + }); + + const runtime = roomState.get(roomId); + expect(runtime?.playback.isPlaying).toBe(false); + expect(runtime?.playback.positionSec).toBe(0); + expect(runtime?.playback.playbackRate).toBe(1); + expect(runtime?.countdown.active).toBe(false); + expect(runtime?.connectedUsers.every((user) => !user.isReady)).toBe(true); + expect(roomState.syncStatus(roomId, hostId)).not.toBe('watching'); + expect(roomEmit).toHaveBeenCalledWith('room:playback-changed', expect.any(Object)); + expect(roomEmit).toHaveBeenCalledWith('room:state', expect.any(Object)); + const playbackChangedPayload = roomEmitPlaybackPayload('room:playback-changed'); + expect(playbackChangedPayload.playback.isPlaying).toBe(false); + expect(playbackChangedPayload.playback.positionSec).toBe(0); + const endStatePayload = roomEmitPlaybackPayload('room:state'); + expect(endStatePayload.playback.isPlaying).toBe(false); + expect(endStatePayload.playback.positionSec).toBe(0); + }); + + it('emits presence-changed when a viewer toggles ready', async () => { + const viewerId = new Types.ObjectId().toString(); + mockHostRoom(initialMovieId); + registry.register('socket-2', { userId: viewerId, userName: 'Viewer' }); + roomState.joinUser({ roomId, userId: viewerId, userName: 'Viewer', socketId: 'socket-2' }); + const viewer = actorSocket('socket-2', viewerId, 'Viewer'); + roomEmit.mockClear(); + + await gateway.handleReadyUpdate(viewer as never, { roomId, isReady: true }); + + expect(roomEmit).toHaveBeenCalledWith('room:presence-changed', expect.any(Object)); + expect(roomEmitHasEvent(REALTIME_SERVER_EVENTS.roomState)).toBe(false); + }); + + it('clears orphan countdown timers when a user rejoins after disconnect mid-countdown', async () => { + mockHostRoom(initialMovieId); + roomState.syncMovie(roomId, initialMovieId); + + await gateway.handlePlaybackUpdate(socket as never, { + roomId, + movieId: initialMovieId, + isPlaying: true, + positionSec: 0, + playbackRate: 1 + }); + expect(roomState.get(roomId)?.countdown.active).toBe(true); + + gateway.handleDisconnect({ + id: 'socket-1', + rooms: new Set([roomId]) + } as never); + + const rejoinSocket = actorSocket('socket-3', hostId, 'Host'); + rejoinSocket.rooms = new Set(); + (rejoinSocket as unknown as { join: jest.Mock }).join = jest.fn((): Promise => { + rejoinSocket.rooms.add(roomId); + return Promise.resolve(); + }); + registry.register('socket-3', { userId: hostId, userName: 'Host' }); + + await gateway.handleJoin(rejoinSocket as never, { roomId }); + + expect(roomState.get(roomId)?.countdown.active).toBe(false); + }); +}); diff --git a/apps/backend/src/realtime/realtime.gateway.ts b/apps/backend/src/realtime/realtime.gateway.ts index 27bd0ef..f130290 100644 --- a/apps/backend/src/realtime/realtime.gateway.ts +++ b/apps/backend/src/realtime/realtime.gateway.ts @@ -1,188 +1,429 @@ -import { Logger } from '@nestjs/common'; +import { Logger, UseFilters, UseGuards } from '@nestjs/common'; import { ConnectedSocket, MessageBody, OnGatewayConnection, OnGatewayDisconnect, + OnGatewayInit, SubscribeMessage, - WebSocketGateway, - WebSocketServer + WebSocketGateway } from '@nestjs/websockets'; +import { WsException } from '@nestjs/websockets'; import type { Server, Socket } from 'socket.io'; +import { REALTIME_CLIENT_EVENTS, REALTIME_SERVER_EVENTS } from '@repo/consts/realtime'; import { joinRoomPayloadSchema, leaveRoomPayloadSchema, + roomModerateUserPayloadSchema, + roomMovieUpdatedPayloadSchema, + roomPlaybackUpdatePayloadSchema, + roomReadyUpdatePayloadSchema, sendMessagePayloadSchema } from '@repo/schemas/realtime'; +import type { + JoinRoomPayload, + LeaveRoomPayload, + RealtimeChatMessage, + RoomModerateUserPayload, + RoomMovieUpdatedPayload, + RoomPlaybackUpdatePayload, + RoomReadyUpdatePayload, + SendMessagePayload +} from '@repo/schemas/realtime'; -import { RoomRepository } from '@/rooms/room.repository'; import { DEFAULT_USER_COLOR } from '@/realtime/realtime.consts'; import type { SocketUserInfo } from '@/realtime/realtime.types'; +import { ConnectionRegistryService } from '@/realtime/services/connection-registry.service'; +import { PlaybackCountdownService } from '@/realtime/services/playback-countdown.service'; +import { RealtimeBroadcastService } from '@/realtime/services/realtime-broadcast.service'; +import { RoomMovieChangeService } from '@/realtime/services/room-movie-change.service'; +import { RoomModerationService } from '@/realtime/services/room-moderation.service'; import { RoomStateService } from '@/realtime/services/room-state.service'; import { SocketAuthService } from '@/realtime/services/socket-auth.service'; - +import { creatorRefToId, type CreatorRefLike } from '@/realtime/utils/creator-ref'; +import { getAuthenticatedUser, WsAuthGuard } from '@/realtime/ws-auth.guard'; +import { WsExceptionFilter } from '@/realtime/ws-exception.filter'; +import { ZodWsValidationPipe } from '@/realtime/zod-ws-validation.pipe'; +import { RoomRepository } from '@/rooms/room.repository'; +import type { RoomDocument } from '@/rooms/room.schema'; + +/** + * Thin transport layer for realtime room events. Authentication is enforced by + * {@link WsAuthGuard}, payloads are validated by {@link ZodWsValidationPipe}, and + * thrown errors are mapped to `room:error` by {@link WsExceptionFilter}. All + * runtime state, countdown timers, broadcasting, and moderation live in + * dedicated services so each handler only orchestrates. + */ @WebSocketGateway({ cors: { origin: true, credentials: true } }) -export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect { - @WebSocketServer() - private readonly server!: Server; - +@UseGuards(WsAuthGuard) +@UseFilters(WsExceptionFilter) +export class RealtimeGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { private readonly logger = new Logger(RealtimeGateway.name); - /** socket.id → authenticated user identity */ - private readonly socketToUser = new Map(); - constructor( private readonly rooms: RoomRepository, private readonly roomState: RoomStateService, - private readonly socketAuth: SocketAuthService + private readonly socketAuth: SocketAuthService, + private readonly registry: ConnectionRegistryService, + private readonly countdown: PlaybackCountdownService, + private readonly broadcast: RealtimeBroadcastService, + private readonly moderation: RoomModerationService, + private readonly movieChange: RoomMovieChangeService ) {} + afterInit(server: Server): void { + this.broadcast.bind(server); + } + async handleConnection(socket: Socket): Promise { - const userInfo = await this.socketAuth.authenticate(socket); - if (!userInfo) { - this.rejectSocket(socket, 'Unauthorized'); + const user = await this.socketAuth.authenticate(socket); + if (!user) { + socket.emit(REALTIME_SERVER_EVENTS.error, { message: 'Unauthorized' }); + socket.disconnect(true); return; } - this.socketToUser.set(socket.id, userInfo); - this.logger.debug(`connect ${socket.id} user=${userInfo.userId}`); - // Signal to the client that auth is complete and room events can be sent. - socket.emit('connection:ack'); + this.registry.register(socket.id, user); + this.logger.debug(`connect ${socket.id} user=${user.userId}`); + socket.emit(REALTIME_SERVER_EVENTS.connectionAck); } handleDisconnect(socket: Socket): void { - const userInfo = this.socketToUser.get(socket.id); - this.socketToUser.delete(socket.id); - if (!userInfo) return; + const user = this.registry.unregister(socket.id); + if (!user) return; for (const roomId of socket.rooms) { if (roomId === socket.id) continue; // default private room - - const result = this.roomState.removeSocket(roomId, socket.id); - // Only notify the room if the user has no other active socket in it. - // On a refresh the new socket already replaced the stale entry in - // handleJoin, so the user is still present under a different socketId. - if (result && !result.userStillConnected) { - this.server.to(roomId).emit('room:user-left', { userId: userInfo.userId, roomId }); - } + this.removeSocketFromRoom(roomId, socket.id, user.userId); } this.logger.debug(`disconnect ${socket.id}`); } - @SubscribeMessage('room:join') + @SubscribeMessage(REALTIME_CLIENT_EVENTS.join) async handleJoin( @ConnectedSocket() socket: Socket, - @MessageBody() data: unknown + @MessageBody(new ZodWsValidationPipe(joinRoomPayloadSchema)) { roomId }: JoinRoomPayload ): Promise { - const userInfo = this.socketToUser.get(socket.id); - if (!userInfo) { - socket.emit('room:error', { message: 'Unauthorized' }); - return; + const user = getAuthenticatedUser(socket); + const room = await this.rooms.findOneAccessibleById(roomId, user.userId); + if (!room) { + throw new WsException('Forbidden'); } - const parsed = joinRoomPayloadSchema.safeParse(data); - if (!parsed.success) { - socket.emit('room:error', { message: 'Invalid payload' }); - return; + await socket.join(roomId); + + const existing = this.roomState.getOrCreate(roomId); + const wasAlreadyConnected = existing.connectedUsers.some((entry) => entry.userId === user.userId); + this.roomState.syncMovie(roomId, this.resolveRoomMovieId(room.movie)); + this.clearOrphanCountdown(roomId); + const joined = this.roomState.joinUser({ + roomId, + userId: user.userId, + userName: user.userName, + socketId: socket.id + }); + await this.syncRoomStatus(roomId, creatorRefToId(room.creator)); + + socket.emit(REALTIME_SERVER_EVENTS.roomState, this.broadcast.buildRoomState(roomId)); + + if (!wasAlreadyConnected) { + this.broadcast.emitUserJoined(roomId, { + userId: user.userId, + userName: user.userName, + color: joined.color, + isReady: joined.isReady, + roomId + }); } - const { roomId } = parsed.data; - const { userId, userName } = userInfo; + this.logger.debug(`${user.userId} joined room ${roomId}`); + } - const room = await this.rooms.findOneAccessibleById(roomId, userId); - if (!room) { - socket.emit('room:error', { message: 'Forbidden' }); - return; + @SubscribeMessage(REALTIME_CLIENT_EVENTS.leave) + async handleLeave( + @ConnectedSocket() socket: Socket, + @MessageBody(new ZodWsValidationPipe(leaveRoomPayloadSchema)) { roomId }: LeaveRoomPayload + ): Promise { + const user = getAuthenticatedUser(socket); + if (this.roomState.isLastSocketLeave(roomId, socket.id)) { + this.roomState.prepareEmptyRoom(roomId); } + const result = this.roomState.removeSocket(roomId, socket.id); + await socket.leave(roomId); - await socket.join(roomId); + if (result && !result.userStillConnected) { + this.broadcast.emitUserLeft(roomId, user.userId); + } + if (this.roomState.get(roomId)) { + this.broadcast.emitRoomPresenceChanged(roomId); + } else { + this.countdown.cancel(roomId); + } - const user = this.roomState.joinUser({ roomId, userId, userName, socketId: socket.id }); - const state = this.roomState.getOrCreate(roomId); + const room = await this.rooms.findRawById(roomId).catch(() => null); + if (room != null) { + if (!this.roomState.get(roomId)) { + await this.finalizeRoomWhenEmpty(roomId, creatorRefToId(room.creator as CreatorRefLike)); + } else { + await this.syncRoomStatus(roomId, creatorRefToId(room.creator as CreatorRefLike)); + } + } + this.logger.debug(`${user.userId} left room ${roomId}`); + } - socket.emit('room:state', { - connectedUsers: state.connectedUsers, - messages: state.messages, - playback: state.playback - }); + @SubscribeMessage(REALTIME_CLIENT_EVENTS.message) + handleMessage( + @ConnectedSocket() socket: Socket, + @MessageBody(new ZodWsValidationPipe(sendMessagePayloadSchema, 'Invalid message')) + { roomId, content }: SendMessagePayload + ): void { + const user = getAuthenticatedUser(socket); + this.assertInRoom(socket, roomId); - this.server.to(roomId).emit('room:user-joined', { - userId, - userName, - color: user.color, - roomId - }); + const message = this.buildChatMessage(roomId, socket.id, user, content); + this.roomState.addMessage(roomId, message); + this.broadcast.emitMessage(roomId, message); + } - this.logger.debug(`${userId} joined room ${roomId}`); + @SubscribeMessage(REALTIME_CLIENT_EVENTS.movieUpdated) + async handleMovieUpdated( + @ConnectedSocket() socket: Socket, + @MessageBody(new ZodWsValidationPipe(roomMovieUpdatedPayloadSchema)) + { roomId, movieId }: RoomMovieUpdatedPayload + ): Promise { + const user = getAuthenticatedUser(socket); + this.assertInRoom(socket, roomId); + const room = await this.assertRoomCreator(roomId, user.userId); + + await this.movieChange.applyMovieChange(roomId, movieId, { + actorUserId: user.userId, + actorUserName: user.userName, + creatorId: creatorRefToId(room.creator) ?? user.userId, + movieName: room.movie_name ?? null + }); } - @SubscribeMessage('room:leave') - async handleLeave( + @SubscribeMessage(REALTIME_CLIENT_EVENTS.playbackUpdate) + async handlePlaybackUpdate( @ConnectedSocket() socket: Socket, - @MessageBody() data: unknown + @MessageBody(new ZodWsValidationPipe(roomPlaybackUpdatePayloadSchema)) + payload: RoomPlaybackUpdatePayload ): Promise { - const userInfo = this.socketToUser.get(socket.id); - if (!userInfo) return; + const user = getAuthenticatedUser(socket); + const { roomId, movieId, isPlaying, positionSec, playbackRate, force, ended } = payload; + this.assertInRoom(socket, roomId); + const room = await this.assertRoomCreator(roomId, user.userId); + + const runtime = this.roomState.getOrCreate(roomId); + const previousPlayback = runtime.playback; + if (!this.isAllowedPlaybackMovieId(runtime, room, movieId)) { + throw new WsException('Forbidden'); + } - const parsed = leaveRoomPayloadSchema.safeParse(data); - if (!parsed.success) { - socket.emit('room:error', { message: 'Invalid payload' }); + if (!isPlaying) { + this.countdown.cancel(roomId); + if (ended === true) { + this.roomState.resetPlaybackSession(roomId); + } else { + this.roomState.updatePlayback(roomId, { movieId, isPlaying: false, positionSec, playbackRate }); + } + await this.syncRoomStatus(roomId, creatorRefToId(room.creator)); + this.broadcast.emitRoomPlaybackChanged(roomId, user.userId); + this.broadcast.emitRoomState(roomId); return; } - const { roomId } = parsed.data; - const { userId } = userInfo; - - this.roomState.removeSocket(roomId, socket.id); - await socket.leave(roomId); + if (previousPlayback.isPlaying) { + this.roomState.updatePlayback(roomId, { movieId, isPlaying: true, positionSec, playbackRate }); + this.countdown.cancel(roomId); + await this.syncRoomStatus(roomId, creatorRefToId(room.creator)); + // Keep server position fresh for joiners, but avoid room-wide playback broadcasts + // during continuous play — those cause viewers to seek/replay and stutter. + const rateChanged = previousPlayback.playbackRate !== playbackRate; + const seeked = + force === true || + Math.abs(positionSec - previousPlayback.positionSec) > 1; + if (rateChanged || seeked) { + this.broadcast.emitRoomPlaybackChanged(roomId, user.userId); + } + return; + } - this.server.to(roomId).emit('room:user-left', { userId, roomId }); - this.logger.debug(`${userId} left room ${roomId}`); + this.assertPlaybackAllowed(roomId, runtime.connectedUsers.length, creatorRefToId(room.creator), force); + this.countdown.setPending(roomId, { movieId, positionSec, playbackRate }); + this.countdown.start(roomId, (id) => { + this.movieChange.finishCountdown(id); + }); + await this.syncRoomStatus(roomId, creatorRefToId(room.creator)); + this.broadcast.emitRoomPresenceChanged(roomId); } - @SubscribeMessage('room:message') - handleMessage(@ConnectedSocket() socket: Socket, @MessageBody() data: unknown): void { - const userInfo = this.socketToUser.get(socket.id); - if (!userInfo) { - socket.emit('room:error', { message: 'Unauthorized' }); - return; + @SubscribeMessage(REALTIME_CLIENT_EVENTS.readyUpdate) + async handleReadyUpdate( + @ConnectedSocket() socket: Socket, + @MessageBody(new ZodWsValidationPipe(roomReadyUpdatePayloadSchema)) + { roomId, isReady }: RoomReadyUpdatePayload + ): Promise { + const user = getAuthenticatedUser(socket); + this.assertInRoom(socket, roomId); + + const room = await this.rooms.findOneAccessibleById(roomId, user.userId); + if (!room) { + throw new WsException('Forbidden'); } - const parsed = sendMessagePayloadSchema.safeParse(data); - if (!parsed.success) { - socket.emit('room:error', { message: 'Invalid message' }); - return; + const updated = this.roomState.setUserReady(roomId, user.userId, isReady); + if (!updated) { + throw new WsException('Forbidden'); } - const { roomId, content } = parsed.data; - const { userId, userName } = userInfo; + await this.syncRoomStatus(roomId, creatorRefToId(room.creator)); + this.broadcast.emitRoomPresenceChanged(roomId); + } - if (!socket.rooms.has(roomId)) { - socket.emit('room:error', { message: 'Forbidden' }); + @SubscribeMessage(REALTIME_CLIENT_EVENTS.kickUser) + async handleKickUser( + @ConnectedSocket() socket: Socket, + @MessageBody(new ZodWsValidationPipe(roomModerateUserPayloadSchema)) + { roomId, targetUserId }: RoomModerateUserPayload + ): Promise { + const user = getAuthenticatedUser(socket); + this.assertInRoom(socket, roomId); + await this.moderation.moderate({ + actorUserId: user.userId, + roomId, + targetUserId, + errorMessage: 'kicked from the room', + shouldBan: false + }); + } + + @SubscribeMessage(REALTIME_CLIENT_EVENTS.blockUser) + async handleBlockUser( + @ConnectedSocket() socket: Socket, + @MessageBody(new ZodWsValidationPipe(roomModerateUserPayloadSchema)) + { roomId, targetUserId }: RoomModerateUserPayload + ): Promise { + const user = getAuthenticatedUser(socket); + this.assertInRoom(socket, roomId); + await this.moderation.moderate({ + actorUserId: user.userId, + roomId, + targetUserId, + errorMessage: 'blocked from the room', + shouldBan: true + }); + } + + private removeSocketFromRoom(roomId: string, socketId: string, userId: string): void { + if (this.roomState.isLastSocketLeave(roomId, socketId)) { + this.roomState.prepareEmptyRoom(roomId); + } + const result = this.roomState.removeSocket(roomId, socketId); + if (result && !result.userStillConnected) { + this.broadcast.emitUserLeft(roomId, userId); + } + if (this.roomState.get(roomId)) { + this.broadcast.emitRoomPresenceChanged(roomId); + void this.syncRoomStatus(roomId); return; } + this.countdown.cancel(roomId); + void this.finalizeRoomWhenEmpty(roomId); + } + + private async finalizeRoomWhenEmpty(roomId: string, creatorId: string | null = null): Promise { + this.countdown.cancel(roomId); + await this.syncRoomStatus(roomId, creatorId); + } + + private clearOrphanCountdown(roomId: string): void { + if (this.countdown.hasTimer(roomId) && this.countdown.getPending(roomId) === undefined) { + this.countdown.cancel(roomId); + } + } + + private assertPlaybackAllowed( + roomId: string, + connectedCount: number, + creatorId: string | null, + force: boolean | undefined + ): void { + const isSoloHost = connectedCount === 1; + const roomReady = this.roomState.computeStatus(roomId, creatorId) === 'ready'; + if (!isSoloHost && !roomReady && force !== true) { + throw new WsException('All users must be ready before playback starts.'); + } + } - const sender = this.roomState.findSocketUser(roomId, socket.id); - const message = { - id: `${socket.id}-${String(Date.now())}`, + private buildChatMessage( + roomId: string, + socketId: string, + user: SocketUserInfo, + content: string + ): RealtimeChatMessage { + const sender = this.roomState.findSocketUser(roomId, socketId); + return { + id: `${socketId}-${String(Date.now())}`, roomId, - userId, - userName, + userId: user.userId, + userName: user.userName, color: sender?.color ?? DEFAULT_USER_COLOR, content, timestamp: new Date().toISOString() }; + } - this.roomState.addMessage(roomId, message); - this.server.to(roomId).emit('room:message-received', message); + private assertInRoom(socket: Socket, roomId: string): void { + if (!socket.rooms.has(roomId)) { + throw new WsException('Forbidden'); + } + } + + private async assertRoomCreator(roomId: string, userId: string): Promise { + const room = await this.rooms.findOneAccessibleById(roomId, userId); + if (!room || creatorRefToId(room.creator) !== userId) { + throw new WsException('Forbidden'); + } + return room; + } + + private resolveRoomMovieId(movie: unknown): string | null { + if (movie == null) return null; + if (typeof movie === 'string') return movie; + if (this.hasDocumentId(movie)) { + return String(movie._id); + } + return null; + } + + /** Accept runtime playback movie id or persisted room.movie during mid-session swaps. */ + private isAllowedPlaybackMovieId( + runtime: { playback: { movieId: string | null } }, + room: RoomDocument, + movieId: string + ): boolean { + const allowed = new Set(); + const dbMovieId = this.resolveRoomMovieId(room.movie); + if (dbMovieId !== null) { + allowed.add(dbMovieId); + } + if (runtime.playback.movieId !== null) { + allowed.add(runtime.playback.movieId); + } + return allowed.has(movieId); + } + + private hasDocumentId(value: unknown): value is { _id: unknown } { + return typeof value === 'object' && value !== null && '_id' in value; } - private rejectSocket(socket: Socket, message: string): void { - socket.emit('room:error', { message }); - socket.disconnect(true); + private async syncRoomStatus(roomId: string, creatorId: string | null = null): Promise { + const status = this.roomState.syncStatus(roomId, creatorId); + await this.rooms.setStatus(roomId, status); } } diff --git a/apps/backend/src/realtime/realtime.module.ts b/apps/backend/src/realtime/realtime.module.ts index ac21ef6..d2685f8 100644 --- a/apps/backend/src/realtime/realtime.module.ts +++ b/apps/backend/src/realtime/realtime.module.ts @@ -1,14 +1,34 @@ import { Module } from '@nestjs/common'; +import { forwardRef } from '@nestjs/common'; import { AuthModule } from '@/auth/auth.module'; import { RoomsModule } from '@/rooms/rooms.module'; +import { REALTIME_BROADCAST_PORT } from '@/realtime/realtime.broadcast-port'; import { RealtimeGateway } from './realtime.gateway'; +import { ConnectionRegistryService } from './services/connection-registry.service'; +import { PlaybackCountdownService } from './services/playback-countdown.service'; +import { RealtimeBroadcastService } from './services/realtime-broadcast.service'; +import { RoomMovieChangeService } from './services/room-movie-change.service'; +import { RoomModerationService } from './services/room-moderation.service'; import { RoomStateService } from './services/room-state.service'; import { SocketAuthService } from './services/socket-auth.service'; +import { WsAuthGuard } from './ws-auth.guard'; @Module({ - imports: [AuthModule, RoomsModule], - providers: [RealtimeGateway, RoomStateService, SocketAuthService] + imports: [AuthModule, forwardRef(() => RoomsModule)], + providers: [ + RealtimeGateway, + RoomStateService, + SocketAuthService, + ConnectionRegistryService, + PlaybackCountdownService, + RealtimeBroadcastService, + RoomMovieChangeService, + RoomModerationService, + WsAuthGuard, + { provide: REALTIME_BROADCAST_PORT, useExisting: RealtimeBroadcastService } + ], + exports: [RoomStateService, REALTIME_BROADCAST_PORT, RoomMovieChangeService] }) export class RealtimeModule {} diff --git a/apps/backend/src/realtime/room-playback-sync.spec.ts b/apps/backend/src/realtime/room-playback-sync.spec.ts new file mode 100644 index 0000000..9dcf1fb --- /dev/null +++ b/apps/backend/src/realtime/room-playback-sync.spec.ts @@ -0,0 +1,58 @@ +import type { PlaybackState } from '@repo/schemas/realtime'; +import { + getMaterializedPlaybackPosition, + shouldUnfreezePlaybackFromRoomState +} from '@repo/schemas/realtime/playback-sync'; + +function makePlayback(overrides: Partial = {}): PlaybackState { + return { + movieId: 'movie-1', + isPlaying: false, + positionSec: 0, + playbackRate: 1, + updatedAt: '2026-06-16T12:00:00.000Z', + ...overrides + }; +} + +describe('playback sync helpers', () => { + it('materializes live playback position while playing', () => { + const position = getMaterializedPlaybackPosition( + makePlayback({ + isPlaying: true, + positionSec: 10, + updatedAt: '2026-06-16T12:00:00.000Z' + }), + Date.parse('2026-06-16T12:00:05.000Z') + ); + expect(position).toBe(15); + }); + + it('unfreezes playback when countdown ends', () => { + const prev = makePlayback({ isPlaying: false }); + const next = makePlayback({ isPlaying: true, positionSec: 12 }); + expect(shouldUnfreezePlaybackFromRoomState(prev, next, true, false)).toBe(true); + }); + + it('unfreezes playback when isPlaying flips', () => { + const prev = makePlayback({ isPlaying: false }); + const next = makePlayback({ isPlaying: true }); + expect(shouldUnfreezePlaybackFromRoomState(prev, next, false, false)).toBe(true); + }); + + it('unfreezes playback when movie id changes', () => { + const prev = makePlayback({ movieId: 'movie-1', isPlaying: true, positionSec: 120 }); + const next = makePlayback({ movieId: 'movie-2', isPlaying: true, positionSec: 0 }); + expect(shouldUnfreezePlaybackFromRoomState(prev, next, false, false)).toBe(true); + }); + + it('does not unfreeze playback for member-only room:state', () => { + const prev = makePlayback({ isPlaying: false, positionSec: 40 }); + const next = makePlayback({ + isPlaying: false, + positionSec: 40, + updatedAt: '2026-06-16T12:00:01.000Z' + }); + expect(shouldUnfreezePlaybackFromRoomState(prev, next, false, false)).toBe(false); + }); +}); diff --git a/apps/backend/src/realtime/services/connection-registry.service.spec.ts b/apps/backend/src/realtime/services/connection-registry.service.spec.ts new file mode 100644 index 0000000..d2e6790 --- /dev/null +++ b/apps/backend/src/realtime/services/connection-registry.service.spec.ts @@ -0,0 +1,47 @@ +import { ConnectionRegistryService } from '@/realtime/services/connection-registry.service'; + +describe('ConnectionRegistryService', () => { + let registry: ConnectionRegistryService; + + beforeEach(() => { + registry = new ConnectionRegistryService(); + }); + + it('resolves the user behind a registered socket', () => { + registry.register('socket-1', { userId: 'user-1', userName: 'Ada' }); + + expect(registry.getUser('socket-1')).toEqual({ userId: 'user-1', userName: 'Ada' }); + expect(registry.getSocketIds('user-1')).toEqual(['socket-1']); + }); + + it('tracks multiple concurrent sockets for the same user', () => { + registry.register('socket-1', { userId: 'user-1', userName: 'Ada' }); + registry.register('socket-2', { userId: 'user-1', userName: 'Ada' }); + + expect(registry.getSocketIds('user-1').sort()).toEqual(['socket-1', 'socket-2']); + }); + + it('removes only the unregistered socket and keeps the others', () => { + registry.register('socket-1', { userId: 'user-1', userName: 'Ada' }); + registry.register('socket-2', { userId: 'user-1', userName: 'Ada' }); + + const removed = registry.unregister('socket-1'); + + expect(removed).toEqual({ userId: 'user-1', userName: 'Ada' }); + expect(registry.getUser('socket-1')).toBeUndefined(); + expect(registry.getSocketIds('user-1')).toEqual(['socket-2']); + }); + + it('drops the user index once the last socket is unregistered', () => { + registry.register('socket-1', { userId: 'user-1', userName: 'Ada' }); + + registry.unregister('socket-1'); + + expect(registry.getSocketIds('user-1')).toEqual([]); + expect(registry.getUser('socket-1')).toBeUndefined(); + }); + + it('returns undefined when unregistering an unknown socket', () => { + expect(registry.unregister('ghost')).toBeUndefined(); + }); +}); diff --git a/apps/backend/src/realtime/services/connection-registry.service.ts b/apps/backend/src/realtime/services/connection-registry.service.ts new file mode 100644 index 0000000..ab93d10 --- /dev/null +++ b/apps/backend/src/realtime/services/connection-registry.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; + +import type { SocketUserInfo } from '@/realtime/realtime.types'; + +/** + * Owns the bidirectional connection index: `socketId -> user` for resolving the + * actor behind an inbound event, and `userId -> socketIds` for addressing every + * live connection a single user holds (multi-tab / multi-device). + */ +@Injectable() +export class ConnectionRegistryService { + private readonly socketToUser = new Map(); + private readonly userToSockets = new Map>(); + + register(socketId: string, user: SocketUserInfo): void { + this.socketToUser.set(socketId, user); + const sockets = this.userToSockets.get(user.userId); + if (sockets) { + sockets.add(socketId); + return; + } + this.userToSockets.set(user.userId, new Set([socketId])); + } + + unregister(socketId: string): SocketUserInfo | undefined { + const user = this.socketToUser.get(socketId); + this.socketToUser.delete(socketId); + if (!user) return undefined; + + const sockets = this.userToSockets.get(user.userId); + if (sockets) { + sockets.delete(socketId); + if (sockets.size === 0) { + this.userToSockets.delete(user.userId); + } + } + return user; + } + + getUser(socketId: string): SocketUserInfo | undefined { + return this.socketToUser.get(socketId); + } + + getSocketIds(userId: string): string[] { + const sockets = this.userToSockets.get(userId); + return sockets ? [...sockets] : []; + } +} diff --git a/apps/backend/src/realtime/services/playback-countdown.service.spec.ts b/apps/backend/src/realtime/services/playback-countdown.service.spec.ts new file mode 100644 index 0000000..9b0c7c9 --- /dev/null +++ b/apps/backend/src/realtime/services/playback-countdown.service.spec.ts @@ -0,0 +1,85 @@ +import { + COUNTDOWN_DURATION_MS, + PlaybackCountdownService +} from '@/realtime/services/playback-countdown.service'; +import { RoomStateService } from '@/realtime/services/room-state.service'; + +describe('PlaybackCountdownService', () => { + const roomId = 'room-1'; + + let roomState: RoomStateService; + let countdown: PlaybackCountdownService; + + beforeEach(() => { + jest.useFakeTimers({ now: Date.parse('2026-06-16T12:00:00.000Z') }); + roomState = new RoomStateService(); + countdown = new PlaybackCountdownService(roomState); + roomState.getOrCreate(roomId); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('stores and clears the pending start', () => { + const pending = { movieId: 'movie-1', positionSec: 5, playbackRate: 1 }; + countdown.setPending(roomId, pending); + + expect(countdown.getPending(roomId)).toEqual(pending); + + countdown.deletePending(roomId); + expect(countdown.getPending(roomId)).toBeUndefined(); + }); + + it('activates the countdown and fires onComplete once the duration elapses', () => { + const onComplete = jest.fn(); + + countdown.start(roomId, onComplete); + + expect(countdown.hasTimer(roomId)).toBe(true); + expect(roomState.get(roomId)?.countdown.active).toBe(true); + expect(onComplete).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(COUNTDOWN_DURATION_MS); + + expect(onComplete).toHaveBeenCalledTimes(1); + expect(onComplete).toHaveBeenCalledWith(roomId); + expect(countdown.hasTimer(roomId)).toBe(false); + }); + + it('does not start a second timer while one is already running', () => { + countdown.start(roomId, jest.fn()); + const firstEndsAt = roomState.get(roomId)?.countdown.endsAt; + + jest.advanceTimersByTime(1_000); + countdown.start(roomId, jest.fn()); + + expect(roomState.get(roomId)?.countdown.endsAt).toBe(firstEndsAt); + }); + + it('ignores start when the room has no runtime state', () => { + const onComplete = jest.fn(); + + countdown.start('missing-room', onComplete); + + expect(countdown.hasTimer('missing-room')).toBe(false); + jest.advanceTimersByTime(COUNTDOWN_DURATION_MS); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('cancel stops the timer, clears pending, and resets the countdown', () => { + const onComplete = jest.fn(); + countdown.start(roomId, onComplete); + countdown.setPending(roomId, { movieId: 'movie-1', positionSec: 0, playbackRate: 1 }); + + countdown.cancel(roomId); + + expect(countdown.hasTimer(roomId)).toBe(false); + expect(countdown.getPending(roomId)).toBeUndefined(); + expect(roomState.get(roomId)?.countdown).toEqual({ active: false, endsAt: null }); + + jest.advanceTimersByTime(COUNTDOWN_DURATION_MS); + expect(onComplete).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/realtime/services/playback-countdown.service.ts b/apps/backend/src/realtime/services/playback-countdown.service.ts new file mode 100644 index 0000000..3404795 --- /dev/null +++ b/apps/backend/src/realtime/services/playback-countdown.service.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; + +import { RoomStateService } from '@/realtime/services/room-state.service'; + +export interface PendingPlaybackStart { + movieId: string; + positionSec: number; + playbackRate: number; +} + +export const COUNTDOWN_DURATION_MS = 3_000; + +/** + * Owns the pre-playback countdown: the per-room timer and the playback start + * queued behind it. Scheduling lives here; the completion side effects (emit + + * status sync) are supplied by the caller via the `onComplete` callback so this + * service stays free of any socket/broadcast coupling. + */ +@Injectable() +export class PlaybackCountdownService { + private readonly timers = new Map(); + private readonly pending = new Map(); + + constructor(private readonly roomState: RoomStateService) {} + + hasTimer(roomId: string): boolean { + return this.timers.has(roomId); + } + + getPending(roomId: string): PendingPlaybackStart | undefined { + return this.pending.get(roomId); + } + + setPending(roomId: string, pending: PendingPlaybackStart): void { + this.pending.set(roomId, pending); + } + + deletePending(roomId: string): void { + this.pending.delete(roomId); + } + + start(roomId: string, onComplete: (roomId: string) => void): void { + const state = this.roomState.get(roomId); + if (!state) return; + if (this.timers.has(roomId)) return; + + const endsAt = new Date(Date.now() + COUNTDOWN_DURATION_MS).toISOString(); + this.roomState.setCountdown(roomId, { active: true, endsAt }); + + const timer = setTimeout(() => { + this.timers.delete(roomId); + onComplete(roomId); + }, COUNTDOWN_DURATION_MS); + this.timers.set(roomId, timer); + } + + cancel(roomId: string): void { + const timer = this.timers.get(roomId); + if (timer !== undefined) { + clearTimeout(timer); + this.timers.delete(roomId); + } + this.pending.delete(roomId); + if (this.roomState.get(roomId)) { + this.roomState.clearCountdown(roomId); + } + } +} diff --git a/apps/backend/src/realtime/services/realtime-broadcast.service.ts b/apps/backend/src/realtime/services/realtime-broadcast.service.ts new file mode 100644 index 0000000..2d44d44 --- /dev/null +++ b/apps/backend/src/realtime/services/realtime-broadcast.service.ts @@ -0,0 +1,154 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { Server, Socket } from 'socket.io'; + +import { REALTIME_SERVER_EVENTS } from '@repo/consts/realtime'; +import type { + RealtimeChatMessage, + RoomPresenceChangedEvent, + RoomStateEvent, + UserJoinedEvent +} from '@repo/schemas/realtime'; + +import type { RealtimeBroadcastPort } from '@/realtime/realtime.broadcast-port'; +import { PlaybackCountdownService } from '@/realtime/services/playback-countdown.service'; +import { RoomStateService } from '@/realtime/services/room-state.service'; +import { RoomRepository } from '@/rooms/room.repository'; + +/** + * Single owner of every outbound socket write. Wraps the Socket.IO server so + * the gateway, moderation, and HTTP layer all emit through one place, and + * implements the {@link RealtimeBroadcastPort} the HTTP layer depends on. + */ +@Injectable() +export class RealtimeBroadcastService implements RealtimeBroadcastPort { + private readonly logger = new Logger(RealtimeBroadcastService.name); + private server: Server | null = null; + + constructor( + private readonly roomState: RoomStateService, + private readonly countdown: PlaybackCountdownService, + private readonly rooms: RoomRepository + ) {} + + bind(server: Server): void { + this.server = server; + } + + getSocket(socketId: string): Socket | undefined { + return this.requireServer().sockets.sockets.get(socketId); + } + + emitToRoom(roomId: string, event: string, payload?: unknown): void { + this.requireServer().to(roomId).emit(event, payload); + } + + disconnectSocket(socketId: string, message?: string): void { + const socket = this.getSocket(socketId); + if (!socket) return; + if (message !== undefined) { + socket.emit(REALTIME_SERVER_EVENTS.error, { message }); + } + socket.disconnect(true); + } + + buildRoomState(roomId: string): RoomStateEvent { + const state = this.roomState.getOrCreate(roomId); + return { + status: state.status, + connectedUsers: state.connectedUsers, + messages: state.messages, + playback: this.roomState.getMaterializedPlayback(roomId), + countdown: state.countdown + }; + } + + buildRoomPresence(roomId: string): RoomPresenceChangedEvent { + const state = this.roomState.getOrCreate(roomId); + return { + roomId, + status: state.status, + connectedUsers: state.connectedUsers, + countdown: state.countdown + }; + } + + emitRoomState(roomId: string): void { + this.emitToRoom(roomId, REALTIME_SERVER_EVENTS.roomState, this.buildRoomState(roomId)); + } + + emitRoomPresenceChanged(roomId: string): void { + this.emitToRoom(roomId, REALTIME_SERVER_EVENTS.presenceChanged, this.buildRoomPresence(roomId)); + } + + emitRoomMovieUpdated(roomId: string, movieId: string, movieName?: string): void { + this.emitToRoom(roomId, REALTIME_SERVER_EVENTS.movieUpdated, { + roomId, + movieId, + ...(movieName !== undefined ? { movieName } : {}) + }); + } + + emitRoomPlaybackChanged(roomId: string, actorUserId: string | null): void { + this.emitToRoom(roomId, REALTIME_SERVER_EVENTS.playbackChanged, { + roomId, + actorUserId, + playback: this.roomState.getMaterializedPlayback(roomId) + }); + } + + emitUserJoined(roomId: string, payload: UserJoinedEvent): void { + this.emitToRoom(roomId, REALTIME_SERVER_EVENTS.userJoined, payload); + } + + emitUserLeft(roomId: string, userId: string): void { + this.emitToRoom(roomId, REALTIME_SERVER_EVENTS.userLeft, { userId, roomId }); + } + + emitMessage(roomId: string, message: RealtimeChatMessage): void { + this.emitToRoom(roomId, REALTIME_SERVER_EVENTS.messageReceived, message); + } + + clearCountdown(roomId: string): void { + this.countdown.cancel(roomId); + } + + async removeRoomMember(roomId: string, userId: string): Promise { + if (!this.roomState.get(roomId)) { + return; + } + + if (this.roomState.isLastUserLeave(roomId, userId)) { + this.roomState.prepareEmptyRoom(roomId); + } + + const result = this.roomState.removeUser(roomId, userId); + if (!result) { + return; + } + + for (const socketId of result.socketIds) { + const socket = this.getSocket(socketId); + if (socket) { + void socket.leave(roomId); + } + } + + this.emitUserLeft(roomId, userId); + + if (this.roomState.get(roomId)) { + this.emitRoomPresenceChanged(roomId); + return; + } + + this.countdown.cancel(roomId); + await this.rooms.setStatus(roomId, 'waiting'); + } + + private requireServer(): Server { + if (!this.server) { + this.logger.error('Realtime server accessed before initialization'); + throw new Error('Realtime server has not been initialized yet'); + } + return this.server; + } +} diff --git a/apps/backend/src/realtime/services/room-moderation.service.spec.ts b/apps/backend/src/realtime/services/room-moderation.service.spec.ts new file mode 100644 index 0000000..b772a02 --- /dev/null +++ b/apps/backend/src/realtime/services/room-moderation.service.spec.ts @@ -0,0 +1,139 @@ +import { Types } from 'mongoose'; +import type { Server } from 'socket.io'; +import { WsException } from '@nestjs/websockets'; + +import { PlaybackCountdownService } from '@/realtime/services/playback-countdown.service'; +import { RealtimeBroadcastService } from '@/realtime/services/realtime-broadcast.service'; +import { RoomModerationService } from '@/realtime/services/room-moderation.service'; +import { RoomStateService } from '@/realtime/services/room-state.service'; +import type { RoomRepository } from '@/rooms/room.repository'; + +describe('RoomModerationService', () => { + const roomId = new Types.ObjectId().toString(); + const hostId = new Types.ObjectId().toString(); + const targetId = new Types.ObjectId().toString(); + + let roomState: RoomStateService; + let countdown: PlaybackCountdownService; + let broadcast: RealtimeBroadcastService; + let moderation: RoomModerationService; + let rooms: jest.Mocked< + Pick + >; + let roomEmit: jest.Mock; + let socketRegistry: Map; + + function mockHostRoom(): void { + rooms.findOneAccessibleById.mockResolvedValue({ + _id: new Types.ObjectId(roomId), + creator: new Types.ObjectId(hostId) + } as never); + } + + function registerSocket(socketId: string): { emit: jest.Mock; disconnect: jest.Mock } { + const entry = { emit: jest.fn(), disconnect: jest.fn() }; + socketRegistry.set(socketId, entry); + return entry; + } + + beforeEach(() => { + roomState = new RoomStateService(); + countdown = new PlaybackCountdownService(roomState); + broadcast = new RealtimeBroadcastService(roomState, countdown, rooms as unknown as RoomRepository); + rooms = { + findOneAccessibleById: jest.fn(), + setStatus: jest.fn(), + banUser: jest.fn() + }; + moderation = new RoomModerationService(rooms as unknown as RoomRepository, roomState, broadcast); + + roomEmit = jest.fn(); + socketRegistry = new Map(); + broadcast.bind({ + to: jest.fn(() => ({ emit: roomEmit })), + sockets: { sockets: socketRegistry } + } as unknown as Server); + + roomState.joinUser({ roomId, userId: hostId, userName: 'Host', socketId: 'host-socket' }); + roomState.joinUser({ roomId, userId: targetId, userName: 'Guest', socketId: 'target-1' }); + roomState.joinUser({ roomId, userId: targetId, userName: 'Guest', socketId: 'target-2' }); + }); + + it('disconnects every socket the target holds and bans when requested', async () => { + mockHostRoom(); + const first = registerSocket('target-1'); + const second = registerSocket('target-2'); + + await moderation.moderate({ + actorUserId: hostId, + roomId, + targetUserId: targetId, + errorMessage: 'You were removed', + shouldBan: true + }); + + expect(rooms.banUser).toHaveBeenCalledTimes(1); + expect(first.disconnect).toHaveBeenCalledWith(true); + expect(second.disconnect).toHaveBeenCalledWith(true); + expect(first.emit).toHaveBeenCalledWith('room:error', { message: 'You were removed' }); + }); + + it('kicks without banning', async () => { + mockHostRoom(); + registerSocket('target-1'); + registerSocket('target-2'); + + await moderation.moderate({ + actorUserId: hostId, + roomId, + targetUserId: targetId, + errorMessage: 'kicked', + shouldBan: false + }); + + expect(rooms.banUser).not.toHaveBeenCalled(); + }); + + it('rejects when the actor is not the room creator', async () => { + mockHostRoom(); + + await expect( + moderation.moderate({ + actorUserId: targetId, + roomId, + targetUserId: hostId, + errorMessage: 'nope', + shouldBan: false + }) + ).rejects.toBeInstanceOf(WsException); + }); + + it('refuses to moderate the room creator', async () => { + mockHostRoom(); + + await expect( + moderation.moderate({ + actorUserId: hostId, + roomId, + targetUserId: hostId, + errorMessage: 'nope', + shouldBan: false + }) + ).rejects.toBeInstanceOf(WsException); + }); + + it('throws when kicking a user that is not in the room', async () => { + mockHostRoom(); + const strangerId = new Types.ObjectId().toString(); + + await expect( + moderation.moderate({ + actorUserId: hostId, + roomId, + targetUserId: strangerId, + errorMessage: 'nope', + shouldBan: false + }) + ).rejects.toBeInstanceOf(WsException); + }); +}); diff --git a/apps/backend/src/realtime/services/room-moderation.service.ts b/apps/backend/src/realtime/services/room-moderation.service.ts new file mode 100644 index 0000000..c7b52fd --- /dev/null +++ b/apps/backend/src/realtime/services/room-moderation.service.ts @@ -0,0 +1,96 @@ +import { Injectable } from '@nestjs/common'; +import { WsException } from '@nestjs/websockets'; +import { Types } from 'mongoose'; + +import type { RoomDocument } from '@/rooms/room.schema'; +import { RealtimeBroadcastService } from '@/realtime/services/realtime-broadcast.service'; +import { RoomStateService } from '@/realtime/services/room-state.service'; +import { creatorRefToId } from '@/realtime/utils/creator-ref'; +import { RoomRepository } from '@/rooms/room.repository'; + +interface ModerateUserOptions { + actorUserId: string; + roomId: string; + targetUserId: string; + errorMessage: string; + shouldBan: boolean; +} + +/** + * Handles kick/block moderation: verifies the actor is the room creator, bans + * when required, and disconnects every live socket the target holds in the room. + */ +@Injectable() +export class RoomModerationService { + constructor( + private readonly rooms: RoomRepository, + private readonly roomState: RoomStateService, + private readonly broadcast: RealtimeBroadcastService + ) {} + + async moderate({ + actorUserId, + roomId, + targetUserId, + errorMessage, + shouldBan + }: ModerateUserOptions): Promise { + const room = await this.assertRoomCreator(roomId, actorUserId); + const creatorId = creatorRefToId(room.creator); + if (targetUserId === creatorId) { + throw new WsException('Forbidden'); + } + + const state = this.roomState.get(roomId); + if (!state) { + throw new WsException('Room not found'); + } + + const targetSocketIds = this.collectUserSocketIds(roomId, targetUserId); + if (targetSocketIds.length === 0 && !shouldBan) { + throw new WsException('User is not in the room'); + } + + if (shouldBan) { + await this.rooms.banUser(roomId, new Types.ObjectId(targetUserId)); + } + + for (const socketId of targetSocketIds) { + this.broadcast.disconnectSocket(socketId, errorMessage); + } + + if (!this.roomState.get(roomId)) { + this.broadcast.clearCountdown(roomId); + } else { + await this.syncStatus(roomId, creatorId); + this.broadcast.emitRoomPresenceChanged(roomId); + } + } + + private collectUserSocketIds(roomId: string, targetUserId: string): string[] { + const state = this.roomState.get(roomId); + if (!state) return []; + + const socketIds = new Set(); + for (const user of state.connectedUsers) { + if (user.userId !== targetUserId) continue; + for (const socketId of user.socketIds) { + socketIds.add(socketId); + } + } + return [...socketIds]; + } + + private async assertRoomCreator(roomId: string, userId: string): Promise { + const room = await this.rooms.findOneAccessibleById(roomId, userId); + if (!room || creatorRefToId(room.creator) !== userId) { + throw new WsException('Forbidden'); + } + return room; + } + + private async syncStatus(roomId: string, creatorId: string | null): Promise { + const status = this.roomState.syncStatus(roomId, creatorId); + await this.rooms.setStatus(roomId, status); + } +} diff --git a/apps/backend/src/realtime/services/room-movie-change.service.ts b/apps/backend/src/realtime/services/room-movie-change.service.ts new file mode 100644 index 0000000..c0b3e7c --- /dev/null +++ b/apps/backend/src/realtime/services/room-movie-change.service.ts @@ -0,0 +1,142 @@ +import { Injectable } from '@nestjs/common'; + +import type { RealtimeChatMessage } from '@repo/schemas/realtime'; + +import { DEFAULT_USER_COLOR } from '@/realtime/realtime.consts'; +import { RealtimeBroadcastService } from '@/realtime/services/realtime-broadcast.service'; +import { PlaybackCountdownService } from '@/realtime/services/playback-countdown.service'; +import { RoomStateService } from '@/realtime/services/room-state.service'; +import { RoomRepository } from '@/rooms/room.repository'; + +export type ApplyMovieChangeOptions = { + actorUserId: string; + actorUserName: string; + creatorId: string; + movieName?: string | null; +}; + +/** + * Single orchestration path for host movie swaps — used by the socket gateway and + * HTTP room updates so viewers always get the same pause-at-zero, ready-reset, + * countdown-pending, and broadcast sequence. + */ +@Injectable() +export class RoomMovieChangeService { + constructor( + private readonly roomState: RoomStateService, + private readonly countdown: PlaybackCountdownService, + private readonly broadcast: RealtimeBroadcastService, + private readonly rooms: RoomRepository + ) {} + + finishCountdown(roomId: string): void { + if (!this.roomState.get(roomId)) { + return; + } + + const pending = this.countdown.getPending(roomId); + if (pending === undefined) { + this.roomState.clearCountdown(roomId); + void this.syncRoomStatus(roomId, null); + this.broadcast.emitRoomPresenceChanged(roomId); + return; + } + + this.countdown.deletePending(roomId); + this.roomState.updatePlayback(roomId, { + movieId: pending.movieId, + isPlaying: true, + positionSec: pending.positionSec, + playbackRate: pending.playbackRate + }); + this.roomState.clearCountdown(roomId); + void this.syncRoomStatus(roomId, null); + this.broadcast.emitRoomPlaybackChanged(roomId, null); + this.broadcast.emitRoomPresenceChanged(roomId); + } + + async applyMovieChange( + roomId: string, + movieId: string, + options: ApplyMovieChangeOptions + ): Promise { + const runtime = this.roomState.getOrCreate(roomId); + const countdownWasActive = runtime.countdown.active; + const hadPendingStart = this.countdown.getPending(roomId) !== undefined; + const playbackRate = runtime.playback.playbackRate; + + this.roomState.syncMovie(roomId, movieId); + this.roomState.setAllUsersReady(roomId, false); + + if (countdownWasActive || hadPendingStart) { + this.countdown.setPending(roomId, { + movieId, + positionSec: 0, + playbackRate + }); + this.countdown.start(roomId, (id) => { + this.finishCountdown(id); + }); + this.roomState.updatePlayback(roomId, { + movieId, + isPlaying: false, + positionSec: 0, + playbackRate + }); + } else { + this.countdown.cancel(roomId); + this.roomState.updatePlayback(roomId, { + movieId, + isPlaying: false, + positionSec: 0, + playbackRate + }); + } + + await this.syncRoomStatus(roomId, options.creatorId); + + const message = this.buildAnnouncement(roomId, options); + this.roomState.addMessage(roomId, message); + const trimmedMovieName = options.movieName?.trim(); + this.broadcast.emitRoomMovieUpdated( + roomId, + movieId, + trimmedMovieName !== undefined && trimmedMovieName.length > 0 + ? trimmedMovieName + : undefined + ); + this.broadcast.emitRoomState(roomId); + this.broadcast.emitRoomPlaybackChanged(roomId, options.actorUserId); + this.broadcast.emitRoomPresenceChanged(roomId); + this.broadcast.emitMessage(roomId, message); + } + + private buildAnnouncement( + roomId: string, + options: ApplyMovieChangeOptions + ): RealtimeChatMessage { + const actor = this.roomState + .get(roomId) + ?.connectedUsers.find((user) => user.userId === options.actorUserId); + const trimmedName = options.movieName?.trim(); + const content = + trimmedName !== undefined && trimmedName.length > 0 + ? `Movie changed to "${trimmedName}". Waiting for the host to play it.` + : 'Movie changed. Waiting for the host to play it.'; + + return { + id: `${options.actorUserId}-${String(Date.now())}`, + roomId, + userId: options.actorUserId, + userName: options.actorUserName, + color: actor?.color ?? DEFAULT_USER_COLOR, + content, + timestamp: new Date().toISOString() + }; + } + + private async syncRoomStatus(roomId: string, creatorId: string | null): Promise { + const status = this.roomState.syncStatus(roomId, creatorId); + await this.rooms.setStatus(roomId, status); + } +} diff --git a/apps/backend/src/realtime/services/room-state.service.spec.ts b/apps/backend/src/realtime/services/room-state.service.spec.ts new file mode 100644 index 0000000..904dd60 --- /dev/null +++ b/apps/backend/src/realtime/services/room-state.service.spec.ts @@ -0,0 +1,123 @@ +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; + +import { RoomStateService } from '@/realtime/services/room-state.service'; + +describe('RoomStateService', () => { + let service: RoomStateService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [RoomStateService] + }).compile(); + + service = module.get(RoomStateService); + }); + + it('tracks readiness, status, and live playback timing', () => { + const roomId = 'room-1'; + service.getOrCreate(roomId); + + const joined = service.joinUser({ + roomId, + userId: 'user-1', + userName: 'User One', + socketId: 'socket-1' + }); + expect(joined.isReady).toBe(false); + expect(joined.socketIds).toEqual(['socket-1']); + expect(service.syncStatus(roomId)).toBe('waiting'); + + service.syncMovie(roomId, 'movie-1'); + service.setUserReady(roomId, 'user-1', true); + expect(service.syncStatus(roomId)).toBe('ready'); + + service.updatePlayback(roomId, { + movieId: 'movie-1', + isPlaying: true, + positionSec: 42, + playbackRate: 1 + }); + + const playback = service.getMaterializedPlayback(roomId, new Date(Date.now() + 1500)); + expect(playback.movieId).toBe('movie-1'); + expect(playback.isPlaying).toBe(true); + expect(playback.positionSec).toBeGreaterThan(43); + + const removed = service.removeSocket(roomId, 'socket-1'); + expect(removed?.userStillConnected).toBe(false); + expect(service.syncStatus(roomId)).toBe('waiting'); + }); + + it('preserves a user ready flag across multiple sockets in the same room', () => { + const roomId = 'room-2'; + service.joinUser({ + roomId, + userId: 'user-1', + userName: 'User One', + socketId: 'socket-old' + }); + service.setUserReady(roomId, 'user-1', true); + + const rejoined = service.joinUser({ + roomId, + userId: 'user-1', + userName: 'User One', + socketId: 'socket-new' + }); + + expect(rejoined.isReady).toBe(true); + expect(rejoined.socketIds).toEqual(['socket-old', 'socket-new']); + + const stillConnected = service.removeSocket(roomId, 'socket-old'); + expect(stillConnected?.userStillConnected).toBe(true); + expect(service.get(roomId)?.connectedUsers).toHaveLength(1); + + const lastSocketRemoved = service.removeSocket(roomId, 'socket-new'); + expect(lastSocketRemoved?.userStillConnected).toBe(false); + expect(service.get(roomId)).toBeUndefined(); + }); + + it('removes every socket for a user when they leave the room entirely', () => { + const roomId = 'room-4'; + service.joinUser({ + roomId, + userId: 'user-1', + userName: 'User One', + socketId: 'socket-1' + }); + service.joinUser({ + roomId, + userId: 'user-1', + userName: 'User One', + socketId: 'socket-2' + }); + + const removed = service.removeUser(roomId, 'user-1'); + + expect(removed?.socketIds).toEqual(['socket-1', 'socket-2']); + expect(service.get(roomId)).toBeUndefined(); + }); + + it('ignores the creator when computing readiness status', () => { + const roomId = 'room-3'; + service.joinUser({ + roomId, + userId: 'creator', + userName: 'Host', + socketId: 'socket-host' + }); + service.joinUser({ + roomId, + userId: 'viewer', + userName: 'Viewer', + socketId: 'socket-viewer' + }); + service.syncMovie(roomId, 'movie-1'); + + service.setUserReady(roomId, 'creator', false); + service.setUserReady(roomId, 'viewer', true); + + expect(service.syncStatus(roomId, 'creator')).toBe('ready'); + }); +}); diff --git a/apps/backend/src/realtime/services/room-state.service.ts b/apps/backend/src/realtime/services/room-state.service.ts index 7ca9fb2..cf09b00 100644 --- a/apps/backend/src/realtime/services/room-state.service.ts +++ b/apps/backend/src/realtime/services/room-state.service.ts @@ -2,8 +2,10 @@ import { Injectable } from '@nestjs/common'; import { REALTIME_MAX_MESSAGES } from '@repo/schemas/realtime'; import type { + CountdownState, ConnectedUser, RealtimeChatMessage, + PlaybackState, RealtimeRoomState } from '@repo/schemas/realtime'; @@ -23,6 +25,13 @@ type RemovalResult = { userStillConnected: boolean; }; +type UserRemovalResult = { + removed: ConnectedUser | null; + socketIds: string[]; +}; + +type RoomRuntimeStatus = RealtimeRoomState['status']; + /** * Owns the in-memory, per-room runtime state (connected users, chat history, * playback). State is never persisted to MongoDB — it lives only for the @@ -43,9 +52,11 @@ export class RoomStateService { const state: RealtimeRoomState = { roomId, + status: 'waiting', connectedUsers: [], messages: [], - playback: this.makeDefaultPlayback() + playback: this.makeDefaultPlayback(), + countdown: this.makeDefaultCountdown() }; this.roomStates.set(roomId, state); return state; @@ -62,23 +73,61 @@ export class RoomStateService { joinUser({ roomId, userId, userName, socketId }: JoinUserInput): ConnectedUser { const state = this.getOrCreate(roomId); - const staleEntry = state.connectedUsers.find((u) => u.userId === userId); - state.connectedUsers = state.connectedUsers.filter((u) => u.userId !== userId); + const existing = state.connectedUsers.find((u) => u.userId === userId); + if (existing) { + if (!existing.socketIds.includes(socketId)) { + existing.socketIds = [...existing.socketIds, socketId]; + } + existing.userName = userName; + return existing; + } const usedColors = new Set(state.connectedUsers.map((u) => u.color)); - const color = staleEntry?.color ?? pickColor(usedColors); - const user: ConnectedUser = { userId, userName, - color, - socketId, - joinedAt: new Date().toISOString() + color: pickColor(usedColors), + socketIds: [socketId], + joinedAt: new Date().toISOString(), + isReady: false }; state.connectedUsers.push(user); return user; } + /** Pause playback and clear countdown before the last member leaves an active watch. */ + prepareEmptyRoom(roomId: string): void { + const state = this.roomStates.get(roomId); + if (!state) { + return; + } + if (state.playback.isPlaying) { + this.resetPlaybackSession(roomId); + return; + } + this.clearCountdown(roomId); + } + + isLastSocketLeave(roomId: string, socketId: string): boolean { + const state = this.roomStates.get(roomId); + if (!state) { + return false; + } + const user = state.connectedUsers.find((entry) => entry.socketIds.includes(socketId)); + if (user === undefined || user.socketIds.length !== 1) { + return false; + } + return state.connectedUsers.length === 1; + } + + isLastUserLeave(roomId: string, userId: string): boolean { + const state = this.roomStates.get(roomId); + if (!state || state.connectedUsers.length !== 1) { + return false; + } + return state.connectedUsers[0]?.userId === userId; + } + /** * Remove a socket from a room, deleting the room when it becomes empty. * Returns `null` when the room has no runtime state. @@ -87,11 +136,22 @@ export class RoomStateService { const state = this.roomStates.get(roomId); if (!state) return null; - const removed = state.connectedUsers.find((u) => u.socketId === socketId) ?? null; - state.connectedUsers = state.connectedUsers.filter((u) => u.socketId !== socketId); + const user = state.connectedUsers.find((u) => u.socketIds.includes(socketId)) ?? null; + if (!user) { + return null; + } - const userStillConnected = - removed !== null && state.connectedUsers.some((u) => u.userId === removed.userId); + const removed = { + ...user, + socketIds: [...user.socketIds] + }; + + user.socketIds = user.socketIds.filter((id) => id !== socketId); + if (user.socketIds.length === 0) { + state.connectedUsers = state.connectedUsers.filter((entry) => entry !== user); + } + + const userStillConnected = user.socketIds.length > 0; if (state.connectedUsers.length === 0) { this.roomStates.delete(roomId); @@ -100,8 +160,62 @@ export class RoomStateService { return { removed, userStillConnected }; } + removeUser(roomId: string, userId: string): UserRemovalResult | null { + const state = this.roomStates.get(roomId); + if (!state) return null; + + const user = state.connectedUsers.find((entry) => entry.userId === userId) ?? null; + if (!user) return null; + + const removed = { + ...user, + socketIds: [...user.socketIds] + }; + + state.connectedUsers = state.connectedUsers.filter((entry) => entry.userId !== userId); + + if (state.connectedUsers.length === 0) { + this.roomStates.delete(roomId); + } + + return { + removed, + socketIds: removed.socketIds + }; + } + findSocketUser(roomId: string, socketId: string): ConnectedUser | undefined { - return this.roomStates.get(roomId)?.connectedUsers.find((u) => u.socketId === socketId); + return this.roomStates.get(roomId)?.connectedUsers.find((u) => u.socketIds.includes(socketId)); + } + + setUserReady(roomId: string, userId: string, isReady: boolean): ConnectedUser | null { + const state = this.roomStates.get(roomId); + if (!state) return null; + + const user = state.connectedUsers.find((entry) => entry.userId === userId); + if (!user) return null; + + user.isReady = isReady; + return user; + } + + setAllUsersReady(roomId: string, isReady: boolean): void { + const state = this.roomStates.get(roomId); + if (!state) return; + + for (const user of state.connectedUsers) { + user.isReady = isReady; + } + } + + setCountdown(roomId: string, countdown: CountdownState): CountdownState { + const state = this.getOrCreate(roomId); + state.countdown = countdown; + return state.countdown; + } + + clearCountdown(roomId: string): CountdownState { + return this.setCountdown(roomId, this.makeDefaultCountdown()); } /** Append a chat message, trimming history to the most recent N messages. */ @@ -113,12 +227,116 @@ export class RoomStateService { } } + syncMovie(roomId: string, movieId: string | null): PlaybackState { + const state = this.getOrCreate(roomId); + if (state.playback.movieId === movieId) { + return state.playback; + } + + state.playback = { + movieId, + isPlaying: false, + positionSec: 0, + playbackRate: 1, + updatedAt: new Date().toISOString() + }; + return state.playback; + } + + updatePlayback(roomId: string, playback: Omit): PlaybackState { + const state = this.getOrCreate(roomId); + state.playback = { + ...playback, + updatedAt: new Date().toISOString() + }; + return state.playback; + } + + /** End-of-watch reset: paused at t=0 and everyone marked not ready. */ + resetPlaybackSession(roomId: string): PlaybackState | null { + const state = this.roomStates.get(roomId); + if (!state || state.playback.movieId === null) { + return null; + } + + this.clearCountdown(roomId); + this.setAllUsersReady(roomId, false); + return this.updatePlayback(roomId, { + movieId: state.playback.movieId, + isPlaying: false, + positionSec: 0, + playbackRate: 1 + }); + } + + getMaterializedPlayback(roomId: string, at = new Date()): PlaybackState { + const state = this.roomStates.get(roomId); + if (!state) { + return this.makeDefaultPlayback(); + } + + if (!state.playback.isPlaying) { + return state.playback; + } + + const elapsedMs = Math.max(0, at.getTime() - new Date(state.playback.updatedAt).getTime()); + const positionSec = state.playback.positionSec + (elapsedMs / 1000) * state.playback.playbackRate; + return { + ...state.playback, + positionSec, + updatedAt: at.toISOString() + }; + } + + setStatus(roomId: string, status: RoomRuntimeStatus): RoomRuntimeStatus { + const state = this.getOrCreate(roomId); + state.status = status; + return state.status; + } + + computeStatus(roomId: string, creatorId: string | null = null): RoomRuntimeStatus { + const state = this.roomStates.get(roomId); + if (!state || state.playback.movieId === null) { + return 'waiting'; + } + if (state.connectedUsers.length === 0) { + return 'waiting'; + } + if (state.playback.isPlaying) { + return 'watching'; + } + const membersToCheck = + creatorId === null ? state.connectedUsers : state.connectedUsers.filter((user) => user.userId !== creatorId); + if (membersToCheck.length === 0) { + return 'waiting'; + } + return membersToCheck.every((user) => user.isReady) ? 'ready' : 'waiting'; + } + + syncStatus(roomId: string, creatorId: string | null = null): RoomRuntimeStatus { + const state = this.roomStates.get(roomId); + if (!state) { + return 'waiting'; + } + + state.status = this.computeStatus(roomId, creatorId); + return state.status; + } + private makeDefaultPlayback(): RealtimeRoomState['playback'] { return { movieId: null, isPlaying: false, positionSec: 0, + playbackRate: 1, updatedAt: new Date().toISOString() }; } + + private makeDefaultCountdown(): CountdownState { + return { + active: false, + endsAt: null + }; + } } diff --git a/apps/backend/src/realtime/services/socket-auth.service.spec.ts b/apps/backend/src/realtime/services/socket-auth.service.spec.ts new file mode 100644 index 0000000..8231657 --- /dev/null +++ b/apps/backend/src/realtime/services/socket-auth.service.spec.ts @@ -0,0 +1,72 @@ +import type { JwtService } from '@nestjs/jwt'; +import type { Socket } from 'socket.io'; + +import type { AuthService } from '@/auth/auth.service'; +import type { UserRepository } from '@/auth/user.repository'; +import { SocketAuthService } from '@/realtime/services/socket-auth.service'; + +describe('SocketAuthService', () => { + let jwt: jest.Mocked>; + let authService: jest.Mocked>; + let users: jest.Mocked>; + let service: SocketAuthService; + + function socketWithCookie(cookie?: string): Socket { + return { handshake: { headers: cookie === undefined ? {} : { cookie } } } as unknown as Socket; + } + + beforeEach(() => { + jwt = { verifyAsync: jest.fn() }; + authService = { assertAccessTokenClaims: jest.fn() }; + users = { findById: jest.fn() }; + service = new SocketAuthService( + jwt as unknown as JwtService, + authService as unknown as AuthService, + users as unknown as UserRepository + ); + }); + + it('returns null when no access cookie is present', async () => { + const result = await service.authenticate(socketWithCookie()); + + expect(result).toBeNull(); + expect(jwt.verifyAsync).not.toHaveBeenCalled(); + }); + + it('resolves the authenticated user for a valid token', async () => { + jwt.verifyAsync.mockResolvedValue({ sub: 'user-1', email: 'a@a.com', pv: 1 } as never); + authService.assertAccessTokenClaims.mockResolvedValue(undefined as never); + users.findById.mockResolvedValue({ userName: 'Ada' } as never); + + const result = await service.authenticate(socketWithCookie('access_token=abc.def.ghi')); + + expect(result).toEqual({ userId: 'user-1', userName: 'Ada' }); + }); + + it('returns null when the token fails verification', async () => { + jwt.verifyAsync.mockRejectedValue(new Error('bad token')); + + const result = await service.authenticate(socketWithCookie('access_token=expired')); + + expect(result).toBeNull(); + }); + + it('returns null when claim assertion fails', async () => { + jwt.verifyAsync.mockResolvedValue({ sub: 'user-1', email: 'a@a.com', pv: 1 } as never); + authService.assertAccessTokenClaims.mockRejectedValue(new Error('stale pv')); + + const result = await service.authenticate(socketWithCookie('access_token=abc')); + + expect(result).toBeNull(); + }); + + it('returns null when the user no longer exists', async () => { + jwt.verifyAsync.mockResolvedValue({ sub: 'user-1', email: 'a@a.com', pv: 1 } as never); + authService.assertAccessTokenClaims.mockResolvedValue(undefined as never); + users.findById.mockResolvedValue(null as never); + + const result = await service.authenticate(socketWithCookie('access_token=abc')); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/backend/src/realtime/services/socket-auth.service.ts b/apps/backend/src/realtime/services/socket-auth.service.ts index 6ad4950..94775ea 100644 --- a/apps/backend/src/realtime/services/socket-auth.service.ts +++ b/apps/backend/src/realtime/services/socket-auth.service.ts @@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt'; import type { Socket } from 'socket.io'; import { AUTH_ACCESS_COOKIE } from '@/auth/auth.consts'; +import { AuthService } from '@/auth/auth.service'; import type { JwtAccessPayload } from '@/auth/auth.types'; import { UserRepository } from '@/auth/user.repository'; import { parseCookies } from '@/realtime/utils/parse-cookies'; @@ -12,6 +13,7 @@ import type { SocketUserInfo } from '@/realtime/realtime.types'; export class SocketAuthService { constructor( private readonly jwt: JwtService, + private readonly authService: AuthService, private readonly users: UserRepository ) {} @@ -22,8 +24,8 @@ export class SocketAuthService { if (!token) return null; try { - // TODO: call authService.assertAccessTokenClaims(payload) to also check passwordVersion const payload = await this.jwt.verifyAsync(token); + await this.authService.assertAccessTokenClaims(payload); const user = await this.users.findById(payload.sub); if (!user) return null; diff --git a/apps/backend/src/realtime/utils/creator-ref.ts b/apps/backend/src/realtime/utils/creator-ref.ts new file mode 100644 index 0000000..cd9d5cc --- /dev/null +++ b/apps/backend/src/realtime/utils/creator-ref.ts @@ -0,0 +1,16 @@ +import { Types } from 'mongoose'; + +export type CreatorRefLike = + | Types.ObjectId + | { _id: Types.ObjectId | string } + | string + | null + | undefined; + +/** Normalize a Mongoose creator reference (raw id or populated doc) to a string id. */ +export function creatorRefToId(creator: CreatorRefLike): string | null { + if (creator == null) return null; + if (typeof creator === 'string') return creator; + if (creator instanceof Types.ObjectId) return creator.toString(); + return String(creator._id); +} diff --git a/apps/backend/src/realtime/ws-auth.guard.ts b/apps/backend/src/realtime/ws-auth.guard.ts new file mode 100644 index 0000000..e865a3e --- /dev/null +++ b/apps/backend/src/realtime/ws-auth.guard.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import type { CanActivate, ExecutionContext } from '@nestjs/common'; +import { WsException } from '@nestjs/websockets'; +import type { Socket } from 'socket.io'; + +import { ConnectionRegistryService } from '@/realtime/services/connection-registry.service'; +import type { SocketUserInfo } from '@/realtime/realtime.types'; + +type AuthenticatedSocketData = { user?: SocketUserInfo }; + +/** + * Rejects message handlers whose socket has no authenticated identity and + * attaches the resolved user to `socket.data` for the handler to read. + */ +@Injectable() +export class WsAuthGuard implements CanActivate { + constructor(private readonly registry: ConnectionRegistryService) {} + + canActivate(context: ExecutionContext): boolean { + const client = context.switchToWs().getClient(); + const user = this.registry.getUser(client.id); + if (!user) { + throw new WsException('Unauthorized'); + } + (client.data as AuthenticatedSocketData).user = user; + return true; + } +} + +/** Read the user the {@link WsAuthGuard} attached, narrowing the optional away. */ +export function getAuthenticatedUser(socket: Socket): SocketUserInfo { + const user = (socket.data as AuthenticatedSocketData).user; + if (!user) { + throw new WsException('Unauthorized'); + } + return user; +} diff --git a/apps/backend/src/realtime/ws-exception.filter.ts b/apps/backend/src/realtime/ws-exception.filter.ts new file mode 100644 index 0000000..cf80fdd --- /dev/null +++ b/apps/backend/src/realtime/ws-exception.filter.ts @@ -0,0 +1,41 @@ +import { Catch, Logger } from '@nestjs/common'; +import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common'; +import { WsException } from '@nestjs/websockets'; +import type { Socket } from 'socket.io'; + +import { REALTIME_SERVER_EVENTS } from '@repo/consts/realtime'; + +/** + * Catches every error thrown by a gateway handler and emits it to the offending + * client as `room:error`. Catch-all (`@Catch()`) so realtime errors never fall + * through to the global HTTP exception filter, which expects an HTTP context. + */ +@Catch() +export class WsExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(WsExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const client = host.switchToWs().getClient(); + client.emit(REALTIME_SERVER_EVENTS.error, { message: this.resolveMessage(exception) }); + } + + private resolveMessage(exception: unknown): string { + if (exception instanceof WsException) { + const error = exception.getError(); + if (typeof error === 'string') { + return error; + } + if ('message' in error) { + const { message } = error as { message?: unknown }; + if (typeof message === 'string') return message; + } + return 'Realtime error'; + } + + this.logger.error( + 'Unhandled realtime exception', + exception instanceof Error ? exception.stack : undefined + ); + return 'Internal error'; + } +} diff --git a/apps/backend/src/realtime/zod-ws-validation.pipe.ts b/apps/backend/src/realtime/zod-ws-validation.pipe.ts new file mode 100644 index 0000000..a26d0ab --- /dev/null +++ b/apps/backend/src/realtime/zod-ws-validation.pipe.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import type { PipeTransform } from '@nestjs/common'; +import { WsException } from '@nestjs/websockets'; +import type { ZodType } from 'zod'; + +/** + * Validates an inbound socket message body against a Zod schema, throwing a + * {@link WsException} (mapped to `room:error` by the gateway filter) on failure. + */ +@Injectable() +export class ZodWsValidationPipe implements PipeTransform { + constructor( + private readonly schema: ZodType, + private readonly message = 'Invalid payload' + ) {} + + transform(value: unknown): T { + const result = this.schema.safeParse(value); + if (!result.success) { + throw new WsException(this.message); + } + return result.data; + } +} diff --git a/apps/backend/src/rooms/room.repository.ts b/apps/backend/src/rooms/room.repository.ts index 4b5b323..364ebf5 100644 --- a/apps/backend/src/rooms/room.repository.ts +++ b/apps/backend/src/rooms/room.repository.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import type { Model } from 'mongoose'; import { Types } from 'mongoose'; -import { RoomRecord, type RoomDocument } from '@/rooms/room.schema'; +import { RoomRecord, type RoomDocument, type RoomStatus } from '@/rooms/room.schema'; @Injectable() export class RoomRepository { @@ -46,7 +46,7 @@ export class RoomRepository { name: string; creator: Types.ObjectId; room_type: string; - status?: string; + status?: RoomStatus; deactivate_at: Date; movie?: Types.ObjectId; password?: string; @@ -65,6 +65,14 @@ export class RoomRepository { ); } + setStatus(roomId: string, status: string): Promise { + return this.model.findByIdAndUpdate( + roomId, + { $set: { status } }, + { new: true } + ); + } + removeUser(roomId: string, userId: Types.ObjectId): Promise { return this.model.findByIdAndUpdate( roomId, diff --git a/apps/backend/src/rooms/room.schema.ts b/apps/backend/src/rooms/room.schema.ts index 4e1b868..867c3f4 100644 --- a/apps/backend/src/rooms/room.schema.ts +++ b/apps/backend/src/rooms/room.schema.ts @@ -8,9 +8,9 @@ export enum RoomType { } export enum RoomStatus { - WATCHING = 'watching', - PREPARING = 'preparing', - READY = 'ready' + WAITING = 'waiting', + READY = 'ready', + WATCHING = 'watching' } @Schema({ @@ -33,7 +33,7 @@ export class RoomRecord { @Prop({ required: true, enum: RoomType }) room_type!: RoomType; - @Prop({ required: true, enum: RoomStatus, default: RoomStatus.PREPARING }) + @Prop({ required: true, enum: RoomStatus, default: RoomStatus.WAITING }) status!: RoomStatus; @Prop({ type: String, default: null }) diff --git a/apps/backend/src/rooms/rooms.module.ts b/apps/backend/src/rooms/rooms.module.ts index 8648329..fe35b21 100644 --- a/apps/backend/src/rooms/rooms.module.ts +++ b/apps/backend/src/rooms/rooms.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; +import { forwardRef } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { MongooseModule } from '@nestjs/mongoose'; import { MoviesModule } from '@/movies/movies.module'; import { AuthModule } from '@/auth/auth.module'; +import { RealtimeModule } from '@/realtime/realtime.module'; import { RoomRecord, RoomSchema } from '@/rooms/room.schema'; import { RoomRepository } from '@/rooms/room.repository'; import { RoomsService } from '@/rooms/rooms.service'; @@ -15,6 +17,7 @@ import { RoomCleanupService } from '@/rooms/room-cleanup.service'; ConfigModule, AuthModule, MoviesModule, + forwardRef(() => RealtimeModule), MongooseModule.forFeature([{ name: RoomRecord.name, schema: RoomSchema }]) ], controllers: [RoomsController], diff --git a/apps/backend/src/rooms/rooms.service.spec.ts b/apps/backend/src/rooms/rooms.service.spec.ts new file mode 100644 index 0000000..c8cab6d --- /dev/null +++ b/apps/backend/src/rooms/rooms.service.spec.ts @@ -0,0 +1,158 @@ +import { ForbiddenException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import { Types } from 'mongoose'; +import type { TestingModule } from '@nestjs/testing'; + +import { MoviesService } from '@/movies/movies.service'; +import { + REALTIME_BROADCAST_PORT, + type RealtimeBroadcastPort +} from '@/realtime/realtime.broadcast-port'; +import { RoomMovieChangeService } from '@/realtime/services/room-movie-change.service'; +import { RoomStateService } from '@/realtime/services/room-state.service'; +import { RoomRepository } from '@/rooms/room.repository'; +import { RoomsService } from '@/rooms/rooms.service'; +import type { Env } from '@/utils/env.validation'; + +describe('RoomsService', () => { + let service: RoomsService; + const roomsRepo = { + findRawById: jest.fn(), + addUser: jest.fn(), + removeUser: jest.fn(), + findOneAccessibleById: jest.fn(), + setStatus: jest.fn(), + banUser: jest.fn(), + create: jest.fn(), + updateIfCreator: jest.fn(), + softDeleteIfCreator: jest.fn(), + setDeactivatedAt: jest.fn() + } as unknown as jest.Mocked; + + const moviesService = { + get: jest.fn(), + scheduleFilePurge: jest.fn() + } as unknown as jest.Mocked; + + const configService = { + get: jest.fn() + } as unknown as ConfigService; + + const roomStateService = { + get: jest.fn(), + syncStatus: jest.fn() + } as unknown as jest.Mocked; + + const realtimeBroadcast = { + emitRoomMovieUpdated: jest.fn(), + emitRoomPlaybackChanged: jest.fn(), + emitRoomState: jest.fn(), + clearCountdown: jest.fn(), + removeRoomMember: jest.fn() + } as unknown as jest.Mocked; + + const movieChangeService = { + applyMovieChange: jest.fn().mockResolvedValue(undefined) + } as unknown as jest.Mocked; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RoomsService, + { provide: RoomRepository, useValue: roomsRepo }, + { provide: MoviesService, useValue: moviesService }, + { provide: ConfigService, useValue: configService }, + { provide: RoomStateService, useValue: roomStateService }, + { provide: REALTIME_BROADCAST_PORT, useValue: realtimeBroadcast }, + { provide: RoomMovieChangeService, useValue: movieChangeService } + ] + }).compile(); + + service = module.get(RoomsService); + }); + + it('rejects a banned user when joining a room', async () => { + const roomId = new Types.ObjectId().toString(); + const creatorId = new Types.ObjectId().toString(); + const userId = new Types.ObjectId().toString(); + + roomsRepo.findRawById.mockResolvedValue({ + _id: new Types.ObjectId(roomId), + creator: new Types.ObjectId(creatorId), + deleted_at: null, + room_type: 'public', + banned_users: [new Types.ObjectId(userId)], + allowed_users: [], + password: null + } as never); + + await expect(service.join(roomId, userId)).rejects.toBeInstanceOf(ForbiddenException); + expect(roomsRepo.addUser.mock.calls).toHaveLength(0); + }); + + it('uses live room occupancy for list counts', async () => { + const roomId = new Types.ObjectId().toString(); + roomStateService.get.mockReturnValue({ + connectedUsers: [{}, {}] + } as never); + roomsRepo.findAllActive = jest.fn().mockResolvedValue([ + { + _id: new Types.ObjectId(roomId), + name: 'Room', + room_type: 'public', + movie: null, + creator: new Types.ObjectId(), + description: null, + password: null, + allowed_users: [], + banned_users: [], + deactivate_at: new Date(), + created_at: new Date(), + updated_at: new Date(), + status: 'waiting', + movie_name: null, + movie_description: null, + creator_name: 'Host' + } + ] as never); + + const rooms = await service.list(); + expect(rooms[0]?.member_count).toBe(2); + }); + + it('clears live room membership when a user leaves', async () => { + const roomId = new Types.ObjectId().toString(); + const userId = new Types.ObjectId().toString(); + const creatorId = new Types.ObjectId().toString(); + + roomsRepo.findRawById.mockResolvedValueOnce({ + _id: new Types.ObjectId(roomId), + creator: new Types.ObjectId(creatorId), + deleted_at: null, + room_type: 'public', + movie: null, + allowed_users: [new Types.ObjectId(userId)], + banned_users: [], + password: null + } as never); + roomsRepo.removeUser.mockResolvedValueOnce(undefined as never); + roomsRepo.findRawById.mockResolvedValueOnce({ + _id: new Types.ObjectId(roomId), + creator: new Types.ObjectId(creatorId), + deleted_at: null, + room_type: 'public', + movie: null, + allowed_users: [], + banned_users: [], + password: null + } as never); + roomStateService.syncStatus.mockReturnValue('waiting'); + + await expect(service.leave(roomId, userId)).resolves.toEqual({ success: true }); + expect((realtimeBroadcast.removeRoomMember as jest.Mock).mock.calls).toEqual([[roomId, userId]]); + expect((roomStateService.syncStatus as jest.Mock).mock.calls).toEqual([[roomId, creatorId]]); + expect((roomsRepo.setStatus as jest.Mock).mock.calls).toEqual([[roomId, 'waiting']]); + }); +}); diff --git a/apps/backend/src/rooms/rooms.service.ts b/apps/backend/src/rooms/rooms.service.ts index a52d1a6..e70e9a3 100644 --- a/apps/backend/src/rooms/rooms.service.ts +++ b/apps/backend/src/rooms/rooms.service.ts @@ -1,5 +1,6 @@ import { ForbiddenException, + Inject, Injectable, NotFoundException } from '@nestjs/common'; @@ -8,6 +9,12 @@ import { Types } from 'mongoose'; import type { CreateRoomInput, RoomPreview, RoomResponse, UpdateRoomInput } from '@repo/schemas/rooms'; import { RoomType, RoomStatus, type RoomDocument } from '@/rooms/room.schema'; import { MoviesService } from '@/movies/movies.service'; +import { + REALTIME_BROADCAST_PORT, + type RealtimeBroadcastPort +} from '@/realtime/realtime.broadcast-port'; +import { RoomStateService } from '@/realtime/services/room-state.service'; +import { RoomMovieChangeService } from '@/realtime/services/room-movie-change.service'; import { RoomRepository } from '@/rooms/room.repository'; import type { Env } from '@/utils/env.validation'; @@ -33,6 +40,7 @@ function toResponse(doc: RoomDocument): RoomResponse { const creatorDoc = doc.creator as unknown as PopulatedUser | null | undefined; const creatorId = creatorDoc != null ? String(creatorDoc._id) : ''; const creatorName = creatorDoc?.userName ?? creatorDoc?.firstName ?? undefined; + const memberCount = allowedUsers.filter((userId) => userId !== creatorId).length; return { id: doc._id.toString(), name: doc.name, @@ -50,21 +58,32 @@ function toResponse(doc: RoomDocument): RoomResponse { movie_name: doc.movie_name ?? null, movie_description: doc.movie_description ?? null, creator_name: creatorName, - member_count: allowedUsers.length + member_count: memberCount }; } +function getLiveMemberCount(roomState: RoomStateService, roomId: string): number { + return roomState.get(roomId)?.connectedUsers.length ?? 0; +} + @Injectable() export class RoomsService { constructor( private readonly rooms: RoomRepository, private readonly movies: MoviesService, - private readonly config: ConfigService + private readonly config: ConfigService, + private readonly roomState: RoomStateService, + @Inject(REALTIME_BROADCAST_PORT) + private readonly realtime: RealtimeBroadcastPort, + private readonly movieChange: RoomMovieChangeService ) {} async list(): Promise { const docs = await this.rooms.findAllActive(); - return docs.map(toResponse); + return docs.map((doc) => ({ + ...toResponse(doc), + member_count: getLiveMemberCount(this.roomState, doc._id.toString()) + })); } async get(id: string, userId: string): Promise { @@ -80,7 +99,7 @@ export class RoomsService { async create(userId: string, data: CreateRoomInput): Promise { let movieName = data.movie_name; let movieDescription = data.movie_description; - let roomStatus: RoomStatus = RoomStatus.PREPARING; + const roomStatus: RoomStatus = RoomStatus.WAITING; if (data.movie !== undefined) { const movie = await this.movies.get(data.movie, userId); if (movieName === undefined) { @@ -89,9 +108,6 @@ export class RoomsService { if (movieDescription === undefined && movie.description != null) { movieDescription = movie.description; } - if (movie.has_file && movie.upload_status === 'ready') { - roomStatus = RoomStatus.READY; - } } const deactivateAt = data.deactivate_at !== undefined ? new Date(data.deactivate_at) @@ -132,6 +148,7 @@ export class RoomsService { if (data.movie !== undefined) { const movie = await this.movies.get(data.movie, userId); patch['movie'] = new Types.ObjectId(data.movie); + patch['status'] = RoomStatus.WAITING; if (data.movie_name === undefined) { patch['movie_name'] = movie.name; } @@ -141,7 +158,41 @@ export class RoomsService { } const doc = await this.rooms.updateIfCreator(id, userId, patch); - if (doc) return toResponse(doc); + if (doc) { + const updatedMovieId = doc.movie != null ? refToId(doc.movie as RefLike) : null; + const shouldBroadcastMovieRefresh = + data.movie !== undefined || + data.movie_name !== undefined || + data.movie_description !== undefined; + if (shouldBroadcastMovieRefresh) { + const roomId = doc._id.toString(); + if (updatedMovieId != null) { + if (data.movie !== undefined) { + const creatorId = doc.creator.toString(); + const creatorDoc = doc.creator as unknown as PopulatedUser | null | undefined; + await this.movieChange.applyMovieChange(roomId, updatedMovieId, { + actorUserId: userId, + actorUserName: creatorDoc?.userName ?? creatorDoc?.firstName ?? 'Host', + creatorId, + movieName: doc.movie_name ?? null + }); + } else { + const creatorId = doc.creator.toString(); + const status = this.roomState.syncStatus(roomId, creatorId); + await this.rooms.setStatus(roomId, status); + this.realtime.emitRoomMovieUpdated( + roomId, + updatedMovieId, + doc.movie_name ?? undefined + ); + this.realtime.emitRoomPlaybackChanged(roomId, null); + this.realtime.emitRoomState(roomId); + } + } + } + + return toResponse(doc); + } const raw = await this.rooms.findRawById(id); if (!raw || raw.deleted_at) { throw new NotFoundException(`Room "${id}" not found`); @@ -162,7 +213,7 @@ export class RoomsService { has_password: raw.password != null && raw.password.length > 0, status: raw.status, creator_name: previewCreator?.userName ?? previewCreator?.firstName ?? undefined, - member_count: safeIds(raw.allowed_users).length + member_count: getLiveMemberCount(this.roomState, raw._id.toString()) }; } @@ -217,6 +268,8 @@ export class RoomsService { if (refToId(raw.creator as RefLike | null | undefined) === userId) { throw new ForbiddenException('The room creator cannot leave their own room'); } + + await this.realtime.removeRoomMember(id, userId); await this.rooms.removeUser(id, new Types.ObjectId(userId)); const updated = await this.rooms.findRawById(id); if (updated && safeIds(updated.allowed_users).length === 0) { @@ -224,6 +277,9 @@ export class RoomsService { const movieId = updated.movie != null ? refToId(updated.movie as RefLike) : null; await this.scheduleLinkedMoviePurge(movieId); } + const creatorId = refToId(raw.creator as RefLike | null | undefined); + const status = this.roomState.syncStatus(id, creatorId); + await this.rooms.setStatus(id, status); return { success: true }; } diff --git a/apps/backend/src/storage/s3-storage.service.ts b/apps/backend/src/storage/s3-storage.service.ts index a3e33ae..7e41d62 100644 --- a/apps/backend/src/storage/s3-storage.service.ts +++ b/apps/backend/src/storage/s3-storage.service.ts @@ -1,67 +1,35 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { DeleteObjectCommand, GetObjectCommand, - ListBucketsCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { - createS3Client, - isCloudflareR2Endpoint, - isR2PlaceholderEndpoint -} from '@/storage/create-s3-client'; +import { createS3Client } from '@/storage/create-s3-client'; import type { PutObjectInput, StorageService, StoredObject } from '@/storage/storage.interface'; import type { Env } from '@/utils/env.validation'; /** S3-compatible object storage (Cloudflare R2 in dev/prod). */ @Injectable() -export class S3StorageService implements StorageService, OnModuleInit { - private readonly logger = new Logger(S3StorageService.name); +export class S3StorageService implements StorageService { private readonly client: S3Client; private readonly bucket: string; - private readonly endpoint: string; constructor(private readonly config: ConfigService) { this.bucket = this.config.get('S3_BUCKET', { infer: true }); - this.endpoint = this.config.get('S3_ENDPOINT', { infer: true }); + const endpoint = this.config.get('S3_ENDPOINT', { infer: true }); this.client = createS3Client({ region: this.config.get('S3_REGION', { infer: true }), - endpoint: this.endpoint, + endpoint, accessKeyId: this.config.get('S3_ACCESS_KEY_ID', { infer: true }), secretAccessKey: this.config.get('S3_SECRET_ACCESS_KEY', { infer: true }), forcePathStyle: this.config.get('S3_FORCE_PATH_STYLE', { infer: true }) }); } - async onModuleInit(): Promise { - if (isR2PlaceholderEndpoint(this.endpoint)) { - return; - } - if (!isCloudflareR2Endpoint(this.endpoint)) { - return; - } - - try { - await this.client.send(new ListBucketsCommand({})); - this.logger.log(`R2 connectivity OK (bucket config: ${this.bucket})`); - } catch (error) { - const host = new URL(this.endpoint).hostname; - const message = error instanceof Error ? error.message : String(error); - this.logger.error( - [ - `R2 connectivity check failed for host "${host}".`, - 'Confirm S3_ENDPOINT uses your Cloudflare Account ID from R2 → Overview (not the API token access key).', - 'EU buckets may need https://.eu.r2.cloudflarestorage.com', - `Underlying error: ${message}` - ].join(' ') - ); - } - } - async putObject(input: PutObjectInput): Promise { await this.client.send( new PutObjectCommand({ diff --git a/apps/backend/test/app.e2e-spec.ts b/apps/backend/test/app.e2e-spec.ts index d344565..9b7919e 100644 --- a/apps/backend/test/app.e2e-spec.ts +++ b/apps/backend/test/app.e2e-spec.ts @@ -477,6 +477,7 @@ describe('Backend bootstrap (e2e)', () => { .expect(201); const room = roomResponseSchema.parse(roomRes.body as unknown); expect(room.creator).toBe(me.userId); + expect(room.status).toBe('waiting'); const stranger = request.agent(app.getHttpServer()); const other = uniqueRegisterBody('dom2'); @@ -545,6 +546,7 @@ describe('Backend bootstrap (e2e)', () => { const updated = roomResponseSchema.parse(patchRes.body as unknown); expect(updated.movie).toBe(movie.id); expect(updated.movie_name).toBe(movie.name); + expect(updated.status).toBe('waiting'); }); it('domain: movie resolve is idempotent; explicit create rejects duplicate names', async () => { diff --git a/apps/frontend/AGENTS.md b/apps/frontend/AGENTS.md index 42ef6c9..3d373b0 100644 --- a/apps/frontend/AGENTS.md +++ b/apps/frontend/AGENTS.md @@ -11,6 +11,7 @@ Vite 8 + React 19 client-side starter surface. Keep it polished, generic, and us - `src/App.tsx` — `CookieAuthProvider` + `react-router-dom` routes: `/` lobby, `/app` (protected shell), `/room/:id`, `/rooms/new`, `/rooms/:id/edit`, `/login`, `/register`, `/verify-email`, `/forgot-password`, `/reset-password`, `/change-password`. - `src/auth/` — cookie session auth: `cookie-auth-provider.tsx` (provider), `use-cookie-auth.ts` (`useCookieAuth` hook), `cookie-auth-context-internal.ts`, `use-cookie-auth-model.ts` (state + `fetch` via `@repo/contracts/auth`), `auth-fetch-helpers.ts`. - `src/pages/` — route screens: lobby, room, create/edit room (create-room uses `POST /api/movies/resolve` + multipart upload + `POST /api/rooms`; edit-room can replace video and sync room/movie metadata), plus auth routes (`login`, `registration`, `verify-email-page`, `forgot-password-page`, `reset-password-page`, `change-password`) and shared `auth-page-shell.tsx` for those auth layouts. +- `src/pages/room.tsx` and `src/hooks/use-room-socket.ts` — realtime room view and Socket.IO client. The backend room state may include multiple live sockets per user; the UI should still treat `connectedUsers` as unique people, not one row per socket. The hook imports event names from `@repo/consts/realtime` (never hardcoded strings), validates `room:join` / `room:leave` payloads with their `@repo/schemas/realtime` schemas, re-emits `room:join` on `connection:ack` to recover after a reconnect, and surfaces `room:error` as a returned `roomError` field (distinct from connection `socketStatus`). HTTP loaders live in `src/pages/room-api.ts`; extracted UI lives in `src/components/{invite-friends,room-password-gate}.tsx`; shared helpers in `src/utils/initials.ts` and `src/movies/room-playback.ts` (`isPlaybackRate`). - `src/movies/` — shared movie upload UI/helpers (`movie-upload-field`, `movie-upload-progress`, `upload-movie-file`, `prepare-movie-for-room`, `movie-metadata-fields`, `format-movie-upload-age`, `recent-owned-movies`, `attach-room-movie`). - `src/home-page.tsx` — optional glassmorphic starter layout (gradient canvas, package verification, endpoint explorer, `auth-panel.tsx` demo + links to `/login` / `/register`); not the default `/` route in `App.tsx`. - `src/auth-panel.tsx` — frosted card: register (with first/last name), verify + resend, forgot + reset, login, refresh + `/me` + logout; `credentials: 'include'` + `@repo/contracts/auth`. diff --git a/apps/frontend/src/components/cinema-chat.tsx b/apps/frontend/src/components/cinema-chat.tsx index 3d0f719..bcb56cb 100644 --- a/apps/frontend/src/components/cinema-chat.tsx +++ b/apps/frontend/src/components/cinema-chat.tsx @@ -1,18 +1,19 @@ -import { useState, useRef, useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import type { ChatMessage } from '@/types/room'; import { Send } from 'lucide-react'; interface CinemaChatProps { messages: ChatMessage[]; onSend: (text: string) => void; + draftMessage: string; + onDraftMessageChange: (value: string) => void; } function formatTime(date: Date): string { return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } -export function CinemaChat({ messages, onSend }: CinemaChatProps) { - const [input, setInput] = useState(''); +export function CinemaChat({ messages, onSend, draftMessage, onDraftMessageChange }: CinemaChatProps) { const endRef = useRef(null); useEffect(() => { @@ -20,10 +21,10 @@ export function CinemaChat({ messages, onSend }: CinemaChatProps) { }, [messages]); const handleSend = () => { - const text = input.trim(); + const text = draftMessage.trim(); if (!text) return; onSend(text); - setInput(''); + onDraftMessageChange(''); }; return ( @@ -76,8 +77,8 @@ export function CinemaChat({ messages, onSend }: CinemaChatProps) { style={{ padding: '7px 12px' }} type="text" placeholder="Say something…" - value={input} - onChange={(e) => { setInput(e.target.value); }} + value={draftMessage} + onChange={(e) => { onDraftMessageChange(e.target.value); }} onKeyDown={(e) => { if (e.key === 'Enter') handleSend(); }} /> + + + + + ); +} diff --git a/apps/frontend/src/components/invite-friends.tsx b/apps/frontend/src/components/invite-friends.tsx new file mode 100644 index 0000000..047e758 --- /dev/null +++ b/apps/frontend/src/components/invite-friends.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react'; +import { Check, Link, UserPlus } from 'lucide-react'; + +import { MOCK_FRIENDS } from '@/data/mock-profile-data'; +import type { Member } from '@/types/room'; +import { initials } from '@/utils/initials'; + +export function InviteFriends({ members }: { members: Member[] }) { + const [copied, setCopied] = useState(null); + const memberUsernames = new Set(members.map((m) => m.username)); + + const copyInvite = (friendId: string) => { + void navigator.clipboard.writeText(window.location.href).then(() => { + setCopied(friendId); + setTimeout(() => { + setCopied(null); + }, 2000); + }); + }; + + return ( +
+
+ + + Invite Friends + +
+ {MOCK_FRIENDS.length === 0 ? ( +

No friends yet.

+ ) : ( +
+ {MOCK_FRIENDS.map((friend) => { + const inRoom = memberUsernames.has(friend.username); + const wasCopied = copied === friend.id; + return ( +
+ + {initials(friend.name)} + +
+

+ {friend.name} +

+

@{friend.username}

+
+ {inRoom ? ( + + In Room + + ) : ( + + )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/components/movie-awaiting-host-overlay.tsx b/apps/frontend/src/components/movie-awaiting-host-overlay.tsx new file mode 100644 index 0000000..127c9e9 --- /dev/null +++ b/apps/frontend/src/components/movie-awaiting-host-overlay.tsx @@ -0,0 +1,68 @@ +import { Clapperboard, Loader2, Play, Radio } from 'lucide-react'; + +interface MovieAwaitingHostOverlayProps { + movieName: string | null | undefined; + loading: boolean; + isHost: boolean; +} + +export function MovieAwaitingHostOverlay({ + movieName, + loading, + isHost +}: MovieAwaitingHostOverlayProps) { + const trimmedName = movieName?.trim(); + const hasName = trimmedName !== undefined && trimmedName.length > 0; + + return ( +
+
+ + + {loading ? ( + <> +

+ Preparing video +

+

Loading new movie…

+ + ) : ( + <> +

+ {isHost ? 'Ready when you are' : 'New movie loaded'} +

+ {hasName && ( +

{trimmedName}

+ )} +

+ {isHost + ? 'Press play on the controls below when you want everyone to watch together.' + : 'The host changed the movie. You will start watching together when they press play.'} +

+ + {!isHost && ( +
+
+ )} + + )} +
+
+ ); +} diff --git a/apps/frontend/src/components/participant-list.tsx b/apps/frontend/src/components/participant-list.tsx index 13e10e0..fcb354e 100644 --- a/apps/frontend/src/components/participant-list.tsx +++ b/apps/frontend/src/components/participant-list.tsx @@ -1,20 +1,19 @@ +import { useState } from 'react'; + import type { Member } from '@/types/room'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; -import { Crown } from 'lucide-react'; +import { AtSign, Ban, Crown, MoreVertical, UserPlus, UserX } from 'lucide-react'; +import { initials } from '@/utils/initials'; interface ParticipantListProps { members: Member[]; currentUserId: string | null; -} - -function initials(name: string): string { - return name - .split(' ') - .map((w) => w[0] ?? '') - .join('') - .slice(0, 2) - .toUpperCase(); + canModerate: boolean; + onAddFriend: (member: Member) => void; + onTagUser: (member: Member) => void; + onKickUser: (member: Member) => void; + onBlockUser: (member: Member) => void; } function presenceRingClass(status: Member['status']): string { @@ -23,7 +22,17 @@ function presenceRingClass(status: Member['status']): string { return 'ring-idle'; } -export function ParticipantList({ members, currentUserId }: ParticipantListProps) { +export function ParticipantList({ + members, + currentUserId, + canModerate, + onAddFriend, + onTagUser, + onKickUser, + onBlockUser +}: ParticipantListProps) { + const [openMenuId, setOpenMenuId] = useState(null); + if (members.length === 0) { return (

{members.map((member) => { const isYou = member.id === currentUserId; + const isMenuOpen = openMenuId === member.id; return (

  • )} + {member.isFriend && ( + + FRIEND + + )} + {member.isReady && ( + + READY + + )}
  • + + + + {isMenuOpen && ( +

    + + + {canModerate && member.id !== currentUserId && ( + <> + + + + )} +
    + )} ); })} diff --git a/apps/frontend/src/components/room-movie-change-notice.tsx b/apps/frontend/src/components/room-movie-change-notice.tsx new file mode 100644 index 0000000..ddb7f09 --- /dev/null +++ b/apps/frontend/src/components/room-movie-change-notice.tsx @@ -0,0 +1,59 @@ +import { Clapperboard, Loader2, Play, Users } from 'lucide-react'; + +interface RoomMovieChangeNoticeProps { + movieName: string | null | undefined; + isHost: boolean; + loading: boolean; +} + +export function RoomMovieChangeNotice({ + movieName, + isHost, + loading +}: RoomMovieChangeNoticeProps) { + const trimmedName = movieName?.trim(); + const hasName = trimmedName !== undefined && trimmedName.length > 0; + + return ( +
    + + +
    +

    + {loading ? 'Loading new movie' : isHost ? 'Movie updated' : 'New movie in this room'} +

    +

    + {hasName ? `"${trimmedName}"` : 'The host picked a new video'} +

    +

    + {loading ? ( + 'Hang tight — the video is preparing for everyone.' + ) : isHost ? ( + 'Press play when you are ready. Everyone will sync with you.' + ) : ( + <> +

    +
    +
    + ); +} diff --git a/apps/frontend/src/components/room-password-gate.tsx b/apps/frontend/src/components/room-password-gate.tsx new file mode 100644 index 0000000..655492e --- /dev/null +++ b/apps/frontend/src/components/room-password-gate.tsx @@ -0,0 +1,74 @@ +import type { RoomPreview } from '@repo/schemas/rooms'; + +interface RoomPasswordGateProps { + preview: RoomPreview; + passwordInput: string; + passwordError: string | null; + joining: boolean; + onPasswordChange: (value: string) => void; + onJoin: () => void; + onBack: () => void; +} + +export function RoomPasswordGate({ + preview, + passwordInput, + passwordError, + joining, + onPasswordChange, + onJoin, + onBack +}: RoomPasswordGateProps) { + return ( +
    +
    +
    + 🔒 +

    + {preview.name} +

    +

    + {preview.has_password ? 'This room is password protected.' : 'This is a private room.'} +

    +
    + {passwordError !== null && ( +
    + {passwordError} +
    + )} + {preview.has_password && ( + { + onPasswordChange(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') onJoin(); + }} + style={{ marginBottom: 12 }} + /> + )} + + +
    +
    + ); +} diff --git a/apps/frontend/src/data/mock-data.ts b/apps/frontend/src/data/mock-data.ts index 63b122d..8326e27 100644 --- a/apps/frontend/src/data/mock-data.ts +++ b/apps/frontend/src/data/mock-data.ts @@ -10,9 +10,9 @@ export const MOCK_CHAT: ChatMessage[] = [ ]; export const MOCK_MEMBERS: Member[] = [ - { id: 'm1', name: 'moriel', username: 'moriel', avatarColor: '#f97316', isHost: true, status: 'active' }, - { id: 'm2', name: 'Alex', username: 'alex_w', avatarColor: '#38bdf8', isHost: false, status: 'active' }, - { id: 'm3', name: 'Sam', username: 'sam_movies', avatarColor: '#a78bfa', isHost: false, status: 'away' }, + { id: 'm1', name: 'moriel', username: 'moriel', avatarColor: '#f97316', isHost: true, isReady: true, isFriend: false, status: 'active' }, + { id: 'm2', name: 'Alex', username: 'alex_w', avatarColor: '#38bdf8', isHost: false, isReady: true, isFriend: true, status: 'active' }, + { id: 'm3', name: 'Sam', username: 'sam_movies', avatarColor: '#a78bfa', isHost: false, isReady: false, isFriend: false, status: 'away' }, ]; export const MOCK_ROOMS: RoomResponse[] = [ @@ -42,7 +42,7 @@ export const MOCK_ROOMS: RoomResponse[] = [ deactivate_at: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), created_at: new Date(Date.now() - 10 * 60000).toISOString(), updated_at: new Date(Date.now() - 2 * 60000).toISOString(), - status: 'preparing', + status: 'waiting', movie_name: 'Dune: Part Two', description: 'Getting ready to enter Arrakis', creator_name: 'alex_w', diff --git a/apps/frontend/src/hooks/use-room-socket.ts b/apps/frontend/src/hooks/use-room-socket.ts index 23e9eb4..288199f 100644 --- a/apps/frontend/src/hooks/use-room-socket.ts +++ b/apps/frontend/src/hooks/use-room-socket.ts @@ -2,31 +2,74 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { io, type Socket } from 'socket.io-client'; import { API_BASE_URL } from '@repo/consts/api'; -import type { - RealtimeChatMessage, - RoomErrorEvent, - RoomStateEvent, - UserJoinedEvent, - UserLeftEvent +import { REALTIME_CLIENT_EVENTS, REALTIME_SERVER_EVENTS } from '@repo/consts/realtime'; +import type { PlaybackState, CountdownState } from '@repo/schemas/realtime'; +import { + joinRoomPayloadSchema, + leaveRoomPayloadSchema, + roomModerateUserPayloadSchema, + realtimeChatMessageSchema, + roomErrorEventSchema, + roomMovieUpdatedEventSchema, + roomMovieUpdatedPayloadSchema, + roomPlaybackChangedEventSchema, + roomPlaybackUpdatePayloadSchema, + roomPresenceChangedEventSchema, + roomReadyUpdatePayloadSchema, + roomStateEventSchema, + sendMessagePayloadSchema, + userJoinedEventSchema, + userLeftEventSchema } from '@repo/schemas/realtime'; +import type { RoomStatus } from '@repo/schemas/rooms'; +import { shouldUnfreezePlaybackFromRoomState } from '@repo/schemas/realtime/playback-sync'; import type { ChatMessage, Member } from '@/types/room'; export type SocketStatus = 'connecting' | 'connected' | 'disconnected' | 'error'; +export interface PlaybackChangeEvent { + actorUserId: string | null; + playback: PlaybackState; +} + interface UseRoomSocketOptions { roomId: string; disabled: boolean; - currentUserId: string | null; creatorId: string; creatorName: string | undefined; initialMemberIds: string[]; + onMovieUpdated?: (movieId: string, movieName?: string) => void; + onPlaybackChanged?: (event: PlaybackChangeEvent) => void; } interface UseRoomSocketReturn { messages: ChatMessage[]; members: Member[]; socketStatus: SocketStatus; + roomError: string | null; + roomState: { + status: RoomStatus; + countdown: CountdownState; + playback: PlaybackState; + connectedUsers: Member[]; + }; sendMessage: (content: string) => void; + sendReadyUpdate: (isReady: boolean) => void; + sendMovieUpdated: (movieId: string) => void; + sendPlaybackUpdate: (playback: PlaybackUpdateInput) => void; + sendKickUser: (targetUserId: string) => void; + sendBlockUser: (targetUserId: string) => void; + playbackEpoch: number; + connectionGeneration: number; +} + +interface PlaybackUpdateInput { + movieId: string; + isPlaying: boolean; + positionSec: number; + playbackRate: number; + force?: boolean; + ended?: boolean; } const FALLBACK_AVATAR_PALETTE = ['#f97316', '#38bdf8', '#a78bfa', '#4ade80', '#fb923c', '#f472b6']; @@ -45,6 +88,8 @@ function memberFromId(id: string, isHost: boolean, displayName?: string, color?: username: label, avatarColor: color ?? colorFromId(id), isHost, + isReady: false, + isFriend: false, status: 'active' }; } @@ -62,28 +107,123 @@ function buildInitialMembers( export function useRoomSocket({ roomId, disabled, - currentUserId, creatorId, creatorName, - initialMemberIds + initialMemberIds, + onMovieUpdated, + onPlaybackChanged }: UseRoomSocketOptions): UseRoomSocketReturn { const [messages, setMessages] = useState([]); const [members, setMembers] = useState(() => buildInitialMembers(creatorId, creatorName, initialMemberIds) ); const [socketStatus, setSocketStatus] = useState('connecting'); + const [roomError, setRoomError] = useState(null); + const [playbackEpoch, setPlaybackEpoch] = useState(0); + const [connectionGeneration, setConnectionGeneration] = useState(0); + const [roomState, setRoomState] = useState(() => ({ + status: 'waiting', + countdown: { active: false, endsAt: null }, + playback: { + movieId: null, + isPlaying: false, + positionSec: 0, + playbackRate: 1, + updatedAt: new Date().toISOString() + }, + connectedUsers: [] + })); const socketRef = useRef(null); const sendMessage = useCallback( (content: string) => { - socketRef.current?.emit('room:message', { roomId, content }); + const payload = sendMessagePayloadSchema.parse({ roomId, content }); + socketRef.current?.emit(REALTIME_CLIENT_EVENTS.message, payload); + }, + [roomId] + ); + + const sendReadyUpdate = useCallback( + (isReady: boolean) => { + const payload = roomReadyUpdatePayloadSchema.parse({ roomId, isReady }); + socketRef.current?.emit(REALTIME_CLIENT_EVENTS.readyUpdate, payload); + }, + [roomId] + ); + + const sendMovieUpdated = useCallback( + (movieId: string) => { + const payload = roomMovieUpdatedPayloadSchema.parse({ roomId, movieId }); + socketRef.current?.emit(REALTIME_CLIENT_EVENTS.movieUpdated, payload); }, [roomId] ); + const sendPlaybackUpdate = useCallback( + (playback: PlaybackUpdateInput) => { + const payload = roomPlaybackUpdatePayloadSchema.parse({ roomId, ...playback }); + socketRef.current?.emit(REALTIME_CLIENT_EVENTS.playbackUpdate, payload); + }, + [roomId] + ); + + const sendKickUser = useCallback( + (targetUserId: string) => { + const payload = roomModerateUserPayloadSchema.parse({ roomId, targetUserId }); + socketRef.current?.emit(REALTIME_CLIENT_EVENTS.kickUser, payload); + }, + [roomId] + ); + + const sendBlockUser = useCallback( + (targetUserId: string) => { + const payload = roomModerateUserPayloadSchema.parse({ roomId, targetUserId }); + socketRef.current?.emit(REALTIME_CLIENT_EVENTS.blockUser, payload); + }, + [roomId] + ); + + const pushPlaybackChange = useCallback( + (event: PlaybackChangeEvent) => { + onPlaybackChanged?.(event); + }, + [onPlaybackChanged] + ); + + const bumpPlaybackEpoch = useCallback(() => { + setPlaybackEpoch((epoch) => epoch + 1); + }, []); + useEffect(() => { if (disabled || !roomId) return; + // Only seed playback from the first room:state per connection; later room:state + // events carry presence/countdown updates without touching playback unless the + // server signals a meaningful playback change (countdown end, isPlaying flip, drift). + let hasInitialSnapshot = false; + + const mapConnectedUsers = ( + users: Array<{ userId: string; userName: string; color: string; isReady: boolean }> + ): Member[] => + users.map((u) => ({ + ...memberFromId(u.userId, u.userId === creatorId, u.userName, u.color), + isReady: u.isReady + })); + + const applyPresence = (payload: { + status: RoomStatus; + connectedUsers: Array<{ userId: string; userName: string; color: string; isReady: boolean }>; + countdown: CountdownState; + }): void => { + setRoomState((prev) => ({ + ...prev, + status: payload.status, + countdown: payload.countdown, + connectedUsers: mapConnectedUsers(payload.connectedUsers) + })); + setMembers(mapConnectedUsers(payload.connectedUsers)); + }; + const socket = io(API_BASE_URL, { withCredentials: true, transports: ['websocket'] @@ -91,30 +231,68 @@ export function useRoomSocket({ socketRef.current = socket; socket.on('connect', () => { - setSocketStatus('connected'); + setSocketStatus('connecting'); // Do not emit room:join here — wait for connection:ack. // The gateway's handleConnection is async (JWT verify + DB lookup). // Socket.IO acknowledges the TCP connection before that promise resolves, // so emitting room:join on 'connect' races against handleConnection and - // causes handleJoin to find an empty socketToUser map → "Unauthorized". + // causes handleJoin to find an empty registry → "Unauthorized". + }); + + socket.on('connect_error', () => { + setSocketStatus('error'); }); - socket.on('connection:ack', () => { - socket.emit('room:join', { roomId }); + // Fires on the first connection AND on every Socket.IO reconnect, since the + // gateway re-runs handleConnection per connection. Re-emitting room:join here + // is what restores room membership after a dropped socket reconnects. + socket.on(REALTIME_SERVER_EVENTS.connectionAck, () => { + setConnectionGeneration((gen) => gen + 1); + socket.emit(REALTIME_CLIENT_EVENTS.join, joinRoomPayloadSchema.parse({ roomId })); }); socket.on('disconnect', () => { setSocketStatus('disconnected'); }); - socket.on('room:state', (data: RoomStateEvent) => { - setMembers( - data.connectedUsers.map((u) => - memberFromId(u.userId, u.userId === creatorId, u.userName, u.color) - ) - ); + socket.on(REALTIME_SERVER_EVENTS.roomState, (data: unknown) => { + const parsed = roomStateEventSchema.safeParse(data); + if (!parsed.success) { + setSocketStatus('error'); + console.error('[room:socket]', 'Invalid room:state payload'); + return; + } + + setRoomError(null); + const bumpAfterStateRef = { current: false }; + setRoomState((prev) => { + const nextPlayback = hasInitialSnapshot + ? shouldUnfreezePlaybackFromRoomState( + prev.playback, + parsed.data.playback, + prev.countdown.active, + parsed.data.countdown.active + ) + ? parsed.data.playback + : prev.playback + : parsed.data.playback; + if (hasInitialSnapshot && nextPlayback !== prev.playback) { + bumpAfterStateRef.current = true; + } + return { + status: parsed.data.status, + countdown: parsed.data.countdown, + playback: nextPlayback, + connectedUsers: mapConnectedUsers(parsed.data.connectedUsers) + }; + }); + if (bumpAfterStateRef.current) { + bumpPlaybackEpoch(); + } + hasInitialSnapshot = true; + setMembers(mapConnectedUsers(parsed.data.connectedUsers)); setMessages( - data.messages.map((m) => ({ + parsed.data.messages.map((m) => ({ id: m.id, userId: m.userId, userName: m.userName, @@ -123,44 +301,137 @@ export function useRoomSocket({ timestamp: new Date(m.timestamp) })) ); + setSocketStatus('connected'); + }); + + socket.on(REALTIME_SERVER_EVENTS.presenceChanged, (data: unknown) => { + const parsed = roomPresenceChangedEventSchema.safeParse(data); + if (!parsed.success) { + console.error('[room:socket]', 'Invalid room:presence-changed payload'); + return; + } + + applyPresence(parsed.data); }); - socket.on('room:message-received', (data: RealtimeChatMessage) => { + socket.on(REALTIME_SERVER_EVENTS.messageReceived, (data: unknown) => { + const parsed = roomMessageSchema.safeParse(data); + if (!parsed.success) { + console.error('[room:socket]', 'Invalid room:message-received payload'); + return; + } + setMessages((prev) => [ ...prev, { - id: data.id, - userId: data.userId, - userName: data.userName, - color: data.color, - content: data.content, - timestamp: new Date(data.timestamp) + id: parsed.data.id, + userId: parsed.data.userId, + userName: parsed.data.userName, + color: parsed.data.color, + content: parsed.data.content, + timestamp: new Date(parsed.data.timestamp) } ]); }); - socket.on('room:user-joined', (data: UserJoinedEvent) => { + socket.on(REALTIME_SERVER_EVENTS.userJoined, (data: unknown) => { + const parsed = roomUserJoinedSchema.safeParse(data); + if (!parsed.success) { + console.error('[room:socket]', 'Invalid room:user-joined payload'); + return; + } + setMembers((prev) => { - if (prev.some((m) => m.id === data.userId)) return prev; - return [...prev, memberFromId(data.userId, data.userId === creatorId, data.userName, data.color)]; + if (prev.some((m) => m.id === parsed.data.userId)) return prev; + return [ + ...prev, + { + ...memberFromId( + parsed.data.userId, + parsed.data.userId === creatorId, + parsed.data.userName, + parsed.data.color + ), + isReady: parsed.data.isReady + } + ]; }); }); - socket.on('room:user-left', (data: UserLeftEvent) => { - setMembers((prev) => prev.filter((m) => m.id !== data.userId)); + socket.on(REALTIME_SERVER_EVENTS.userLeft, (data: unknown) => { + const parsed = roomUserLeftSchema.safeParse(data); + if (!parsed.success) { + console.error('[room:socket]', 'Invalid room:user-left payload'); + return; + } + + setMembers((prev) => prev.filter((m) => m.id !== parsed.data.userId)); }); - socket.on('room:error', (data: RoomErrorEvent) => { - setSocketStatus('error'); - console.error('[room:socket]', data.message); + socket.on(REALTIME_SERVER_EVENTS.error, (data: unknown) => { + const parsed = roomErrorEventSchema.safeParse(data); + if (!parsed.success) { + setRoomError('Realtime error'); + console.error('[room:socket]', 'Invalid room:error payload'); + return; + } + + setRoomError(parsed.data.message); + console.error('[room:socket]', parsed.data.message); + }); + + socket.on(REALTIME_SERVER_EVENTS.movieUpdated, (data: unknown) => { + const parsed = roomMovieUpdatedEventSchema.safeParse(data); + if (!parsed.success) { + console.error('[room:socket]', 'Invalid room:movie-updated payload'); + return; + } + + onMovieUpdated?.(parsed.data.movieId, parsed.data.movieName); + }); + + socket.on(REALTIME_SERVER_EVENTS.playbackChanged, (data: unknown) => { + const parsed = roomPlaybackChangedEventSchema.safeParse(data); + if (!parsed.success) { + console.error('[room:socket]', 'Invalid room:playback-changed payload'); + return; + } + + const countdownComplete = + parsed.data.actorUserId === null && parsed.data.playback.isPlaying; + setRoomState((prev) => ({ + ...prev, + playback: parsed.data.playback, + countdown: countdownComplete ? { active: false, endsAt: null } : prev.countdown + })); + bumpPlaybackEpoch(); + pushPlaybackChange({ actorUserId: parsed.data.actorUserId, playback: parsed.data.playback }); }); return () => { - socket.emit('room:leave', { roomId }); + socket.emit(REALTIME_CLIENT_EVENTS.leave, leaveRoomPayloadSchema.parse({ roomId })); socket.disconnect(); socketRef.current = null; }; - }, [roomId, disabled, currentUserId, creatorId]); + }, [roomId, disabled, creatorId, onMovieUpdated, pushPlaybackChange, bumpPlaybackEpoch]); - return { messages, members, socketStatus, sendMessage }; + return { + messages, + members, + socketStatus, + roomError, + roomState, + sendMessage, + sendReadyUpdate, + sendMovieUpdated, + sendPlaybackUpdate, + sendKickUser, + sendBlockUser, + playbackEpoch, + connectionGeneration + }; } + +const roomMessageSchema = realtimeChatMessageSchema; +const roomUserJoinedSchema = userJoinedEventSchema; +const roomUserLeftSchema = userLeftEventSchema; diff --git a/apps/frontend/src/index.css b/apps/frontend/src/index.css index 30cd994..fbc495b 100644 --- a/apps/frontend/src/index.css +++ b/apps/frontend/src/index.css @@ -34,9 +34,9 @@ --status-watching-text: #1b263b; --status-watching-border: rgba(65, 90, 119, 0.35); - --status-preparing-bg: rgba(119, 141, 169, 0.16); - --status-preparing-text: #415a77; - --status-preparing-border: rgba(119, 141, 169, 0.40); + --status-waiting-bg: rgba(245, 158, 11, 0.14); + --status-waiting-text: #f59e0b; + --status-waiting-border: rgba(245, 158, 11, 0.38); --status-ready-bg: rgba(34, 197, 94, 0.12); --status-ready-text: #15803d; @@ -341,10 +341,10 @@ textarea.input { color: var(--status-watching-text); border: 1px solid var(--status-watching-border); } -.badge-preparing { - background: var(--status-preparing-bg); - color: var(--status-preparing-text); - border: 1px solid var(--status-preparing-border); +.badge-waiting { + background: var(--status-waiting-bg); + color: var(--status-waiting-text); + border: 1px solid var(--status-waiting-border); } .badge-ready { background: var(--status-ready-bg); @@ -409,7 +409,7 @@ textarea.input { 100% { transform: scale(0.85); opacity: 0; } } .countdown-digit { - animation: countdown-pop 1s ease forwards; + animation: countdown-pop 0.4s ease forwards; } /* ── Animations ─────────────────────────────────────────────── */ @@ -1242,7 +1242,7 @@ textarea.input { /* ── Room card top accent line ──────────────────────────────── */ .room-card-watching { border-top: 2px solid var(--status-watching-text); } -.room-card-preparing { border-top: 2px solid var(--status-preparing-text); } +.room-card-waiting { border-top: 2px solid var(--status-waiting-text); } .room-card-ready { border-top: 2px solid var(--status-ready-text); } /* ── Video controls ─────────────────────────────────────────── */ @@ -1450,9 +1450,9 @@ input[type='range']::-moz-range-thumb { --status-watching-text: #fb923c; --status-watching-border: rgba(249, 115, 22, 0.4); - --status-preparing-bg: rgba(245, 158, 11, 0.15); - --status-preparing-text: #fbbf24; - --status-preparing-border: rgba(245, 158, 11, 0.4); + --status-waiting-bg: rgba(245, 158, 11, 0.15); + --status-waiting-text: #fbbf24; + --status-waiting-border: rgba(245, 158, 11, 0.4); --status-ready-bg: rgba(34, 197, 94, 0.15); --status-ready-text: #4ade80; @@ -1511,6 +1511,48 @@ input[type='range']::-moz-range-thumb { overflow: hidden; } +@media (max-width: 1100px) { + .room-main { + flex-direction: column; + overflow-y: auto; + } + + .room-player-column { + flex: none; + border-right: none; + border-bottom: 1px solid var(--border-subtle); + } + + .room-video-area { + flex: none; + aspect-ratio: auto; + } + + .room-video-area .room-video-player { + flex: none; + width: 100%; + } + + .room-video-area .room-video-player__body { + flex: none; + } + + .room-video-area .room-video-player__stage { + aspect-ratio: 16 / 9; + flex: none; + height: auto; + width: 100%; + } + + .room-sidebar { + width: 100%; + flex: none; + min-height: 260px; + border-left: none; + border-top: 1px solid var(--border-subtle); + } +} + /* ── Responsive breakpoints ──────────────────────────────────── */ /* Mobile — up to 767px */ @@ -1550,46 +1592,6 @@ input[type='range']::-moz-range-thumb { height: 22px; } - /* Room: stack video + sidebar vertically */ - .room-main { - flex-direction: column; - overflow-y: auto; - } - .room-player-column { - flex: none; - border-right: none; - border-bottom: 1px solid var(--border-subtle); - } - /* Let the area grow naturally — aspect-ratio moves to the stage */ - .room-video-area { - flex: none; - aspect-ratio: auto; - } - /* The player chain becomes natural height on mobile */ - .room-video-area .room-video-player { - flex: none; - width: 100%; - } - .room-video-area .room-video-player__body { - flex: none; - } - /* Stage gets the 16:9 ratio so controls sit below it, not inside it */ - .room-video-area .room-video-player__stage { - aspect-ratio: 16 / 9; - flex: none; - height: auto; - width: 100%; - } - /* Sidebar fills the remaining viewport height */ - .room-sidebar { - width: 100%; - flex: 1; - min-height: 200px; - height: auto; - border-left: none; - border-top: 1px solid var(--border-subtle); - } - /* Room top bar */ header .btn-primary, header .btn-danger { diff --git a/apps/frontend/src/movies/movie-upload-field.tsx b/apps/frontend/src/movies/movie-upload-field.tsx index bb16654..5bce43b 100644 --- a/apps/frontend/src/movies/movie-upload-field.tsx +++ b/apps/frontend/src/movies/movie-upload-field.tsx @@ -1,4 +1,4 @@ -import { useRef } from 'react'; +import { useRef, useState } from 'react'; import { MOVIE_FILE_ACCEPT, validateMovieFile } from '@/movies/upload-movie-file'; import { MOVIE_ALLOWED_FORMATS_LABEL } from '@repo/consts/movies'; @@ -19,6 +19,7 @@ export function MovieUploadField({ disabled?: boolean; }) { const fileInputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); const handleChange = (next: File | null) => { if (next === null) { @@ -45,56 +46,149 @@ export function MovieUploadField({ fileInputRef.current?.click(); } }} + onDragEnter={(e) => { + if (disabled) return; + e.preventDefault(); + setIsDragging(true); + }} + onDragOver={(e) => { + if (disabled) return; + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={(e) => { + if (disabled) return; + e.preventDefault(); + setIsDragging(false); + }} + onDrop={(e) => { + if (disabled) return; + e.preventDefault(); + setIsDragging(false); + handleChange(e.dataTransfer.files[0] ?? null); + }} style={{ marginTop: 6, - padding: '20px', - border: '2px dashed var(--border-medium)', - borderRadius: 10, + padding: '18px', + border: `1px solid ${file ? 'var(--accent)' : isDragging ? 'var(--accent)' : error ? '#f87171' : 'var(--border-medium)'}`, + borderRadius: 14, textAlign: 'center', - transition: 'border-color 200ms ease, background 200ms ease', - background: file ? 'var(--accent-dim)' : 'transparent', - borderColor: file ? 'var(--accent)' : error ? '#f87171' : 'var(--border-medium)', + transition: 'border-color 200ms ease, background 200ms ease, transform 200ms ease', + background: file ? 'var(--accent-dim)' : isDragging ? 'rgba(124,58,237,0.08)' : 'transparent', cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.7 : 1, + minHeight: 158, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', }} > {file ? ( -
    -

    🎬

    -

    - {file.name} -

    -

    - {(file.size / 1024 / 1024).toFixed(1)} MB -

    - {onRemove && ( +
    +
    +
    + 🎬 +
    +
    +
    +

    + {file.name} +

    +

    + {(file.size / 1024 / 1024).toFixed(1)} MB · Selected for upload +

    +
    +
    - )} + {onRemove && ( + + )} +
    ) : ( -
    -

    📁

    -

    - Click to upload a video file -

    -

    - {MOVIE_ALLOWED_FORMATS_LABEL}, up to 1 GB -

    +
    +
    +
    + 📁 +
    +
    +
    +

    + Drop a video file here or browse +

    +

    + {MOVIE_ALLOWED_FORMATS_LABEL}, up to 1 GB +

    +
    +
    + + Click to choose + or drag and drop + +
    )}
    diff --git a/apps/frontend/src/movies/prepare-movie-for-room.ts b/apps/frontend/src/movies/prepare-movie-for-room.ts index 434a4da..ebf3083 100644 --- a/apps/frontend/src/movies/prepare-movie-for-room.ts +++ b/apps/frontend/src/movies/prepare-movie-for-room.ts @@ -5,10 +5,6 @@ import type { CreateMovieInput, MovieResponse } from '@repo/schemas/movies'; import { readHttpErrorMessage } from '@/auth/auth-fetch-helpers'; import { uploadMovieFile, type UploadProgress } from '@/movies/upload-movie-file'; -export function shouldReplaceMovieFile(movie: MovieResponse): boolean { - return movie.has_file || movie.upload_status === 'ready'; -} - /** Resolve or create a movie, then upload the video file for room creation. */ export async function prepareMovieForRoom( body: CreateMovieInput, @@ -29,7 +25,7 @@ export async function prepareMovieForRoom( const movie = resolveMovieContract.responseSchema.parse(await resolveRes.json()); return uploadMovieFile(movie.id, file, { - replace: shouldReplaceMovieFile(movie), + replace: true, ...(options?.onProgress !== undefined && { onProgress: options.onProgress }), }); } diff --git a/apps/frontend/src/movies/room-playback.ts b/apps/frontend/src/movies/room-playback.ts index 0a849b9..4d03e8a 100644 --- a/apps/frontend/src/movies/room-playback.ts +++ b/apps/frontend/src/movies/room-playback.ts @@ -1,9 +1,28 @@ +import type { PlaybackState } from '@repo/schemas/realtime'; +import { + getMaterializedPlaybackPosition, + PLAYBACK_DRIFT_THRESHOLD_SEC, + PLAYBACK_JOIN_DRIFT_THRESHOLD_SEC +} from '@repo/schemas/realtime/playback-sync'; + +export { + getMaterializedPlaybackPosition, + PLAYBACK_DRIFT_THRESHOLD_SEC, + PLAYBACK_JOIN_DRIFT_THRESHOLD_SEC, + PLAYBACK_UNFREEZE_DRIFT_SEC, + shouldUnfreezePlaybackFromRoomState +} from '@repo/schemas/realtime/playback-sync'; + export const SEEK_STEPS_SECONDS = [5] as const; export const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 2] as const; export type PlaybackRate = (typeof PLAYBACK_RATES)[number]; +export function isPlaybackRate(rate: number): rate is PlaybackRate { + return PLAYBACK_RATES.some((candidate) => candidate === rate); +} + export function formatSeekLabel(seconds: number): string { if (seconds >= 3600 && seconds % 3600 === 0) { return `${String(seconds / 3600)}h`; @@ -28,3 +47,68 @@ export function formatPlaybackTime(seconds: number): string { export function formatRateLabel(rate: number): string { return rate === 1 ? '1×' : `${String(rate)}×`; } + +export type ApplyServerPlaybackResult = { + currentTime: number; + isPlaying: boolean; + playbackRate: PlaybackRate; +}; + +export type ApplyServerPlaybackMode = 'full' | 'soft' | 'join'; + +/** Snap the local video element to the server-authoritative host playback snapshot. */ +export function applyServerPlaybackToVideo( + video: HTMLVideoElement, + playback: PlaybackState, + roomMovieId: string, + mode: ApplyServerPlaybackMode = 'full', + driftThresholdSec?: number, + onPlayRejected?: () => void +): ApplyServerPlaybackResult | null { + if (playback.movieId !== roomMovieId) { + return null; + } + + const resolvedDriftThreshold = + driftThresholdSec ?? + (mode === 'join' ? PLAYBACK_JOIN_DRIFT_THRESHOLD_SEC : PLAYBACK_DRIFT_THRESHOLD_SEC); + + const playbackRate: PlaybackRate = isPlaybackRate(playback.playbackRate) ? playback.playbackRate : 1; + if (video.playbackRate !== playbackRate) { + video.playbackRate = playbackRate; + } + + const truthPosition = getMaterializedPlaybackPosition(playback); + const drift = Math.abs(video.currentTime - truthPosition); + if (drift > resolvedDriftThreshold) { + video.currentTime = truthPosition; + } + + const alignPlayPause = mode === 'full' || mode === 'join'; + if (alignPlayPause) { + if (playback.isPlaying) { + if (video.paused || video.ended) { + void video.play().catch(() => { + onPlayRejected?.(); + }); + } + } else if (!video.paused) { + video.pause(); + } + } else { + // soft: only nudge play/pause when out of sync — avoids reload stutter on join/leave + if (playback.isPlaying && (video.paused || video.ended)) { + void video.play().catch(() => { + onPlayRejected?.(); + }); + } else if (!playback.isPlaying && !video.paused) { + video.pause(); + } + } + + return { + currentTime: video.currentTime, + isPlaying: playback.isPlaying, + playbackRate + }; +} diff --git a/apps/frontend/src/movies/room-video-player-controls.tsx b/apps/frontend/src/movies/room-video-player-controls.tsx index c7ad48b..2a215af 100644 --- a/apps/frontend/src/movies/room-video-player-controls.tsx +++ b/apps/frontend/src/movies/room-video-player-controls.tsx @@ -1,28 +1,14 @@ +import { useState } from 'react'; + import { formatPlaybackTime, formatRateLabel, + isPlaybackRate, PLAYBACK_RATES, SEEK_STEPS_SECONDS, type PlaybackRate } from '@/movies/room-playback'; -function PlayIcon() { - return ( - - ); -} - -function PauseIcon() { - return ( - - ); -} - function VolumeIcon({ muted }: { muted: boolean }) { return muted ? (
    {!isFullscreen && (movieName != null || statusText != null) && ( @@ -122,77 +121,87 @@ export function RoomVideoPlayerControls({ )}
    - {formatPlaybackTime(currentTime)} + {formatPlaybackTime(displayTime)} { onInteract(); onScrub(Number(e.target.value)); }} + onPointerDown={() => { + onInteract(); + setIsScrubbing(true); + setScrubValue(currentTime); + }} + onPointerUp={commitScrub} + onPointerCancel={commitScrub} + onChange={(e) => { + onInteract(); + const next = Number(e.target.value); + setScrubValue(next); + if (!isScrubbing) { + onScrub(next); + } + }} aria-label="Playback position" /> {formatPlaybackTime(duration)}
    -
    - - - - - -
    + {showHostControls && ( +
    + -
    - + +5s + +
    + )} + +
    + {showHostControls && ( + <> + + + )}
    )} - {mediaSrc !== null && !videoReady && !videoError && ( + {showAwaitingHostOverlay && ( + + )} + + {mediaSrc !== null && + !videoReady && + !videoError && + currentTime === 0 && + !showAwaitingHostOverlay && (
    Loading video…
    )} diff --git a/apps/frontend/src/movies/upload-movie-file.ts b/apps/frontend/src/movies/upload-movie-file.ts index f4ef5e4..dbe6618 100644 --- a/apps/frontend/src/movies/upload-movie-file.ts +++ b/apps/frontend/src/movies/upload-movie-file.ts @@ -26,6 +26,42 @@ export function validateMovieFile(file: File): string | null { return null; } +function readUploadErrorMessage(status: number, response: unknown): string { + if (typeof response === 'object' && response !== null) { + if ('detail' in response && typeof response.detail === 'string') { + return response.detail; + } + if ('errors' in response && Array.isArray(response.errors) && response.errors.length > 0) { + const first: unknown = response.errors[0]; + if ( + typeof first === 'object' && + first !== null && + 'message' in first && + typeof first.message === 'string' + ) { + return first.message; + } + } + } + + if (typeof response === 'string' && response.trim().length > 0) { + return response; + } + + if (status === 401) { + return 'Your session expired. Sign in again and retry the upload.'; + } + + return `Upload failed (HTTP ${String(status)})`; +} + +function parseUploadResponse(response: unknown): MovieResponse { + if (typeof response === 'string') { + return uploadMovieContract.responseSchema.parse(JSON.parse(response)); + } + return uploadMovieContract.responseSchema.parse(response); +} + export function uploadMovieFile( movieId: string, file: File, @@ -40,6 +76,7 @@ export function uploadMovieFile( xhr.open('POST', `${API_BASE_URL}${path}${query}`); xhr.withCredentials = true; xhr.responseType = 'json'; + xhr.setRequestHeader('Accept', 'application/json'); xhr.upload.onprogress = (event) => { if (event.lengthComputable && options.onProgress) { @@ -54,24 +91,26 @@ export function uploadMovieFile( xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { try { - resolve(uploadMovieContract.responseSchema.parse(xhr.response)); + resolve(parseUploadResponse(xhr.response)); } catch (error) { - reject(error instanceof Error ? error : new Error('Invalid upload response')); + reject( + error instanceof Error + ? new Error(`Upload completed but the server response was invalid: ${error.message}`) + : new Error('Upload completed but the server response was invalid.') + ); } return; } - const message = - typeof xhr.response === 'object' && - xhr.response !== null && - 'detail' in xhr.response && - typeof (xhr.response as { detail: unknown }).detail === 'string' - ? (xhr.response as { detail: string }).detail - : `Upload failed (HTTP ${String(xhr.status)})`; - reject(new Error(message)); + + reject(new Error(readUploadErrorMessage(xhr.status, xhr.response))); }; xhr.onerror = () => { - reject(new Error('Upload failed')); + reject( + new Error( + 'Upload failed due to a network error. Check that the backend is running on port 3000 and try again.' + ) + ); }; const formData = new FormData(); diff --git a/apps/frontend/src/movies/use-room-movie.ts b/apps/frontend/src/movies/use-room-movie.ts index 65c5aed..f5eb9f6 100644 --- a/apps/frontend/src/movies/use-room-movie.ts +++ b/apps/frontend/src/movies/use-room-movie.ts @@ -1,25 +1,13 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { API_BASE_URL } from '@repo/consts/api'; -import { MOVIE_MEDIA_ENDPOINT } from '@repo/consts/movies'; -import { getMovieContract } from '@repo/contracts/movies'; +import { getMovieContract, streamMovieContract } from '@repo/contracts/movies'; import type { MovieResponse } from '@repo/schemas/movies'; import { readHttpErrorMessage } from '@/auth/auth-fetch-helpers'; -const POLL_MS = 3000; - -function movieMediaPath(movieId: string): string { - return MOVIE_MEDIA_ENDPOINT.replace(':id', encodeURIComponent(movieId)); -} - -function movieMediaUrl(movieId: string, cacheKey?: string | null): string { - const url = new URL(`${API_BASE_URL}${movieMediaPath(movieId)}`); - if (cacheKey != null && cacheKey.length > 0) { - url.searchParams.set('v', cacheKey); - } - return url.toString(); -} +const UPLOAD_POLL_MS = 2_000; +const STREAM_REFRESH_BUFFER_MS = 60_000; async function fetchRoomMovie(movieId: string): Promise { const params = getMovieContract.paramsSchema.parse({ id: movieId }); @@ -34,6 +22,23 @@ async function fetchRoomMovie(movieId: string): Promise { return getMovieContract.responseSchema.parse(await res.json()); } +async function fetchStreamUrl(movieId: string): Promise<{ url: string; expiresAtMs: number }> { + const params = streamMovieContract.paramsSchema.parse({ id: movieId }); + const path = streamMovieContract.path.replace(':id', encodeURIComponent(params.id)); + const res = await fetch(`${API_BASE_URL}${path}`, { + credentials: 'include', + headers: { Accept: 'application/json' } + }); + if (!res.ok) { + throw new Error(await readHttpErrorMessage(res)); + } + const body = streamMovieContract.responseSchema.parse(await res.json()); + return { + url: body.url, + expiresAtMs: new Date(body.expires_at).getTime() + }; +} + function isMoviePlayable(movie: MovieResponse): boolean { return movie.has_file && movie.upload_status === 'ready'; } @@ -42,60 +47,127 @@ function isMovieUploading(movie: MovieResponse): boolean { return movie.upload_status === 'pending'; } -export function useRoomMovie(movieId: string | null | undefined) { +function isCancelled(ref: { current: boolean }): boolean { + return ref.current; +} + +export function useRoomMovie( + movieId: string | null | undefined, + streamRevision = 0 +) { const [movie, setMovie] = useState(null); + const [mediaSrc, setMediaSrc] = useState(null); const [loading, setLoading] = useState(Boolean(movieId)); const [error, setError] = useState(null); + const cancelledRef = useRef(false); + const inFlightRef = useRef(false); + const streamRefreshTimerRef = useRef | null>(null); useEffect(() => { if (movieId == null || movieId.length === 0) { setMovie(null); + setMediaSrc(null); setLoading(false); setError(null); return; } - let cancelled = false; - let pollTimer: ReturnType | undefined; + cancelledRef.current = false; + inFlightRef.current = false; + setMovie(null); + setMediaSrc(null); + setError(null); + setLoading(true); + + let pollTimer: ReturnType | null = null; + + const clearStreamRefresh = (): void => { + if (streamRefreshTimerRef.current !== null) { + clearTimeout(streamRefreshTimerRef.current); + streamRefreshTimerRef.current = null; + } + }; + + const stopPolling = (): void => { + if (pollTimer !== null) { + clearInterval(pollTimer); + pollTimer = null; + } + }; + + const scheduleStreamRefresh = (expiresAtMs: number): void => { + clearStreamRefresh(); + const refreshInMs = Math.max(5_000, expiresAtMs - Date.now() - STREAM_REFRESH_BUFFER_MS); + streamRefreshTimerRef.current = setTimeout(() => { + void refreshStream(); + }, refreshInMs); + }; + + const refreshStream = async (): Promise => { + if (isCancelled(cancelledRef)) return; + try { + const stream = await fetchStreamUrl(movieId); + if (isCancelled(cancelledRef)) return; + setMediaSrc(stream.url); + setError(null); + scheduleStreamRefresh(stream.expiresAtMs); + } catch (err: unknown) { + if (!isCancelled(cancelledRef)) { + setError(err instanceof Error ? err.message : 'Failed to load stream URL'); + setMediaSrc(null); + } + } + }; const load = async () => { + if (isCancelled(cancelledRef) || inFlightRef.current) return; + inFlightRef.current = true; + try { const next = await fetchRoomMovie(movieId); - if (cancelled) return; + if (isCancelled(cancelledRef)) return; + setMovie(next); - setError(null); - if (isMovieUploading(next) && pollTimer === undefined) { - pollTimer = setInterval(() => { void load(); }, POLL_MS); - } - if (isMoviePlayable(next) && pollTimer !== undefined) { - clearInterval(pollTimer); - pollTimer = undefined; + const playable = isMoviePlayable(next); + + if (playable) { + stopPolling(); + await refreshStream(); + } else { + setMediaSrc(null); + setError(null); } } catch (err: unknown) { - if (!cancelled) { + if (!isCancelled(cancelledRef)) { setError(err instanceof Error ? err.message : 'Failed to load movie'); + setMediaSrc(null); } } finally { - if (!cancelled) setLoading(false); + inFlightRef.current = false; + if (!isCancelled(cancelledRef)) { + setLoading(false); + } } }; - setLoading(true); + pollTimer = setInterval(() => { + void load(); + }, UPLOAD_POLL_MS); + void load(); return () => { - cancelled = true; - if (pollTimer !== undefined) clearInterval(pollTimer); + cancelledRef.current = true; + stopPolling(); + clearStreamRefresh(); }; - }, [movieId]); + }, [movieId, streamRevision]); return { movie, loading, error, - mediaSrc: movieId != null && movie != null && isMoviePlayable(movie) - ? movieMediaUrl(movieId, movie.file_uploaded_at ?? movie.updated_at) - : null, + mediaSrc, isUploading: movie != null && isMovieUploading(movie), isPlayable: movie != null && isMoviePlayable(movie), isFailed: movie?.upload_status === 'failed' diff --git a/apps/frontend/src/movies/use-room-playback-sync.ts b/apps/frontend/src/movies/use-room-playback-sync.ts new file mode 100644 index 0000000..ef0efb7 --- /dev/null +++ b/apps/frontend/src/movies/use-room-playback-sync.ts @@ -0,0 +1,207 @@ +import { useEffect, useRef } from 'react'; +import type { CountdownState, PlaybackState } from '@repo/schemas/realtime'; + +import type { PlaybackChangeEvent } from '@/hooks/use-room-socket'; +import { + applyServerPlaybackToVideo, + type ApplyServerPlaybackMode, + type PlaybackRate +} from '@/movies/room-playback'; + +export interface UseRoomPlaybackSyncOptions { + videoRef: React.RefObject; + roomMovieId: string | null; + mediaSrc: string | null; + videoReady: boolean; + posterFrameReady: boolean; + isOwner: boolean; + currentUserId: string | null; + playback: PlaybackState; + countdown: CountdownState; + connectionGeneration: number; + remotePlaybackEvent: PlaybackChangeEvent | null; + suppressPlaybackEmitRef: React.RefObject; + onApplied: (result: { currentTime: number; isPlaying: boolean; playbackRate: PlaybackRate }) => void; + onPlayFailed: () => void; + onMovieMismatch: () => void; + onRemoteEventHandled: () => void; +} + +/** + * Aligns the local video element with server-authoritative host playback. + * Initial snapshot: viewers snap once (join mode); host stays local (soft). + * After that: explicit room:playback-changed events, plus recovery when the + * server is playing but the local element is still paused (post-swap / countdown). + */ +export function useRoomPlaybackSync({ + videoRef, + roomMovieId, + mediaSrc, + videoReady, + posterFrameReady, + isOwner, + currentUserId, + playback, + countdown, + connectionGeneration, + remotePlaybackEvent, + suppressPlaybackEmitRef, + onApplied, + onPlayFailed, + onMovieMismatch, + onRemoteEventHandled +}: UseRoomPlaybackSyncOptions): void { + const initialSyncDoneRef = useRef(false); + const prevCountdownActiveRef = useRef(false); + const lastConnectionGenerationRef = useRef(-1); + const lastSyncedMovieIdRef = useRef(null); + const playbackRef = useRef(playback); + + playbackRef.current = playback; + + useEffect(() => { + initialSyncDoneRef.current = false; + lastSyncedMovieIdRef.current = null; + }, [roomMovieId]); + + useEffect(() => { + if (connectionGeneration !== lastConnectionGenerationRef.current) { + initialSyncDoneRef.current = false; + lastSyncedMovieIdRef.current = null; + lastConnectionGenerationRef.current = connectionGeneration; + } + }, [connectionGeneration]); + + useEffect(() => { + const video = videoRef.current; + if (video === null || roomMovieId === null) { + return; + } + if ( + lastSyncedMovieIdRef.current !== null && + lastSyncedMovieIdRef.current !== roomMovieId + ) { + initialSyncDoneRef.current = false; + video.pause(); + } + }, [roomMovieId, videoRef]); + + useEffect(() => { + const video = videoRef.current; + const snapshotPlayback = playbackRef.current; + const pendingPlayback = remotePlaybackEvent?.playback ?? snapshotPlayback; + const serverPlaying = pendingPlayback.isPlaying && !countdown.active; + const mediaReadyForPaused = videoReady || posterFrameReady; + if ( + video === null || + roomMovieId === null || + mediaSrc === null || + pendingPlayback.movieId !== roomMovieId + ) { + return; + } + + if (serverPlaying) { + if (!videoReady) { + return; + } + } else if (!mediaReadyForPaused) { + return; + } + + const countdownJustEnded = prevCountdownActiveRef.current && !countdown.active; + prevCountdownActiveRef.current = countdown.active; + + const isPlaybackChangedEvent = remotePlaybackEvent !== null; + const skipHostEcho = + isOwner && + isPlaybackChangedEvent && + remotePlaybackEvent.actorUserId === currentUserId; + + if (skipHostEcho) { + onRemoteEventHandled(); + return; + } + + const isInitialSync = !initialSyncDoneRef.current; + const localPaused = video.paused || video.ended; + const needsStuckPlayRecovery = !isPlaybackChangedEvent && serverPlaying && localPaused; + + if (!isInitialSync && !isPlaybackChangedEvent && !needsStuckPlayRecovery) { + return; + } + + const snapshot = isPlaybackChangedEvent ? remotePlaybackEvent.playback : snapshotPlayback; + const swappedMovie = + lastSyncedMovieIdRef.current !== null && + lastSyncedMovieIdRef.current !== roomMovieId; + const needsJoinSnap = + isInitialSync || + swappedMovie || + (!isOwner && + snapshot.isPlaying && + (needsStuckPlayRecovery || lastSyncedMovieIdRef.current !== roomMovieId)); + const mode: ApplyServerPlaybackMode = needsJoinSnap ? (isOwner ? 'soft' : 'join') : 'soft'; + + suppressPlaybackEmitRef.current = true; + try { + if (snapshot.movieId !== roomMovieId) { + onMovieMismatch(); + return; + } + + const applied = applyServerPlaybackToVideo( + video, + snapshot, + roomMovieId, + mode, + undefined, + onPlayFailed + ); + if (applied === null) { + return; + } + + onApplied(applied); + + if ( + countdownJustEnded && + snapshot.isPlaying && + (video.paused || video.ended) + ) { + void video.play().catch(() => { + onPlayFailed(); + }); + } + + initialSyncDoneRef.current = true; + lastSyncedMovieIdRef.current = roomMovieId; + } finally { + queueMicrotask(() => { + suppressPlaybackEmitRef.current = false; + }); + } + + if (isPlaybackChangedEvent) { + onRemoteEventHandled(); + } + }, [ + connectionGeneration, + countdown.active, + currentUserId, + isOwner, + mediaSrc, + onApplied, + onMovieMismatch, + onPlayFailed, + onRemoteEventHandled, + playback.isPlaying, + playback.movieId, + posterFrameReady, + remotePlaybackEvent, + roomMovieId, + suppressPlaybackEmitRef, + videoReady, + videoRef + ]); +} diff --git a/apps/frontend/src/pages/edit-room.tsx b/apps/frontend/src/pages/edit-room.tsx index 04ece3e..a7e4321 100644 --- a/apps/frontend/src/pages/edit-room.tsx +++ b/apps/frontend/src/pages/edit-room.tsx @@ -411,7 +411,9 @@ export function EditRoom() { const movieFileDisplay = drafts.movieFile ? drafts.movieFile.name - : 'No new file selected'; + : room.movie + ? 'Ready to replace the current file' + : 'No video file uploaded yet'; const readyOwnedMovies = ownedMovies.filter((movie) => movie.has_file && movie.upload_status === 'ready'); const selectedOwnedMovieAge = formatMovieUploadAge(ownedMovies.find((movie) => movie.id === selectedMovieId)?.file_uploaded_at); const roomTitleSummary = room.name; @@ -667,7 +669,7 @@ export function EditRoom() { - { - setDrafts((prev) => ({ - ...prev, - movieFile: file, - ...(file && !prev.movieName ? { movieName: file.name.replace(/\.[^.]+$/, '') } : {}), - })); - setMovieFileError(validationError); - }} - onRemove={() => { - setDrafts((prev) => ({ ...prev, movieFile: null })); - setMovieFileError(null); - }} - /> -

    - {drafts.movieFile - ? 'This is the replacement file currently selected for upload. Remove it to pick a different one.' - : `Upload a new file to replace the current room movie (${MOVIE_ALLOWED_FORMATS_LABEL}, up to 1 GB). The video file is separate from the room title and movie title.`} -

    - {uploadPercent !== null && } +
    +
    +

    + Drop a new video file here or browse to replace the room movie. The file starts uploading only after you press the upload button below. +

    +

    + {room.movie + ? `Current movie: ${room.movie_name ?? 'Untitled movie'}` + : 'No movie is attached yet. Uploading a file will attach it to this room.'} +

    +
    + + { + setDrafts((prev) => ({ + ...prev, + movieFile: file, + ...(file && !prev.movieName ? { movieName: file.name.replace(/\.[^.]+$/, '') } : {}), + })); + setMovieFileError(validationError); + }} + onRemove={() => { + setDrafts((prev) => ({ ...prev, movieFile: null })); + setMovieFileError(null); + }} + /> + +
    + + +
    + +

    + {drafts.movieFile + ? 'The selected file will replace the current room movie after upload finishes.' + : `Supported formats: ${MOVIE_ALLOWED_FORMATS_LABEL}. Maximum size: 1 GB.`} +

    + + {uploadPercent !== null && ( + + )} +
    { + const data = (await res.json().catch(() => ({}))) as Record; + return typeof data['detail'] === 'string' ? data['detail'] : `HTTP ${String(res.status)}`; +} + +export async function fetchRoom(id: string): Promise { + const res = await fetch(roomPath(getRoomContract.path, id), { + headers: { Accept: 'application/json' }, + credentials: 'include' + }); + if (!res.ok) throw new Error(`HTTP ${String(res.status)}`); + return getRoomContract.responseSchema.parse(await res.json()); +} + +export async function fetchRoomPreview(id: string): Promise { + const res = await fetch(roomPath(previewRoomContract.path, id), { + headers: { Accept: 'application/json' }, + credentials: 'include' + }); + if (!res.ok) throw new Error(`HTTP ${String(res.status)}`); + return previewRoomContract.responseSchema.parse(await res.json()); +} + +export async function joinRoom(id: string, password: string | undefined): Promise { + const body = joinRoomContract.bodySchema.parse(password !== undefined ? { password } : {}); + const res = await fetch(roomPath(joinRoomContract.path, id), { + method: joinRoomContract.method, + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + credentials: 'include', + body: JSON.stringify(body) + }); + if (!res.ok) { + throw new Error(await readErrorDetail(res)); + } +} + +export async function leaveRoom(id: string): Promise { + const res = await fetch(roomPath(leaveRoomContract.path, id), { + method: leaveRoomContract.method, + headers: { Accept: 'application/json' }, + credentials: 'include' + }); + if (!res.ok) { + throw new Error(await readErrorDetail(res)); + } +} + +export async function fetchCurrentUserId(): Promise { + try { + const res = await fetch(`${API_BASE_URL}${getAuthMeContract.path}`, { + headers: { Accept: 'application/json' }, + credentials: 'include' + }); + if (!res.ok) return null; + const me = getAuthMeContract.responseSchema.parse(await res.json()); + return me.userId; + } catch { + return null; + } +} diff --git a/apps/frontend/src/pages/room.tsx b/apps/frontend/src/pages/room.tsx index 27d8df4..3d2dfac 100644 --- a/apps/frontend/src/pages/room.tsx +++ b/apps/frontend/src/pages/room.tsx @@ -1,16 +1,13 @@ -import { useState, useRef, useEffect } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { API_BASE_URL } from '@repo/consts/api'; -import { getRoomContract, previewRoomContract, joinRoomContract, leaveRoomContract } from '@repo/contracts/rooms'; -import { getAuthMeContract } from '@repo/contracts/auth'; -import type { RoomPreview } from '@repo/schemas/rooms'; -import type { RoomResponse, RoomStatus } from '@repo/schemas/rooms'; -import { useRoomSocket } from '@/hooks/use-room-socket'; +import type { RoomPreview, RoomResponse, RoomStatus } from '@repo/schemas/rooms'; +import { useRoomSocket, type PlaybackChangeEvent } from '@/hooks/use-room-socket'; import { attachMovieToRoom } from '@/movies/attach-room-movie'; import { MovieUploadField } from '@/movies/movie-upload-field'; import { MovieUploadProgress } from '@/movies/movie-upload-progress'; import { RoomVideoPlayer } from '@/movies/room-video-player'; import type { PlaybackRate } from '@/movies/room-playback'; +import { useRoomPlaybackSync } from '@/movies/use-room-playback-sync'; import { useRoomMovie } from '@/movies/use-room-movie'; import { prepareMovieForRoom } from '@/movies/prepare-movie-for-room'; import { validateMovieFile } from '@/movies/upload-movie-file'; @@ -18,113 +15,49 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { ParticipantList } from '@/components/participant-list'; import { CinemaChat } from '@/components/cinema-chat'; import { CountdownOverlay } from '@/components/countdown-overlay'; -import { Users, MessageSquare, Volume2, VolumeX, UserPlus, Check, Link } from 'lucide-react'; +import { ForcePlayConfirmationModal } from '@/components/force-play-confirmation-modal'; +import { InviteFriends } from '@/components/invite-friends'; +import { RoomMovieChangeNotice } from '@/components/room-movie-change-notice'; +import { RoomPasswordGate } from '@/components/room-password-gate'; +import { Users, MessageSquare, Volume2, VolumeX } from 'lucide-react'; import { MOCK_FRIENDS } from '@/data/mock-profile-data'; import type { Member } from '@/types/room'; +import { + fetchCurrentUserId, + fetchRoom, + fetchRoomPreview, + joinRoom, + leaveRoom +} from '@/pages/room-api'; +import { RoomStatusBadge } from '@/rooms/room-status-badge'; +import { roomStatusShortLabel } from '@/rooms/room-status-display'; -function initials(name: string): string { - return name.split(' ').map((w) => w[0] ?? '').join('').slice(0, 2).toUpperCase(); +function canCurrentUserAccessRoomMovie( + currentUserId: string | null, + room: RoomResponse | null +): boolean { + if (currentUserId === null || room === null) { + return false; + } + return currentUserId === room.creator || room.allowed_users.includes(currentUserId); } -function InviteFriends({ members }: { members: Member[] }) { - const [copied, setCopied] = useState(null); - const memberUsernames = new Set(members.map((m) => m.username)); - - const handleInvite = (friendId: string) => { - void navigator.clipboard.writeText(window.location.href).then(() => { - setCopied(friendId); - setTimeout(() => { setCopied(null); }, 2000); - }); - }; - - return ( -
    -
    - - - Invite Friends - -
    - {MOCK_FRIENDS.length === 0 ? ( -

    No friends yet.

    - ) : ( -
    - {MOCK_FRIENDS.map((friend) => { - const inRoom = memberUsernames.has(friend.username); - const wasCopied = copied === friend.id; - return ( -
    - - {initials(friend.name)} - -
    -

    - {friend.name} -

    -

    @{friend.username}

    -
    - {inRoom ? ( - - In Room - - ) : ( - - )} -
    - ); - })} -
    - )} -
    -
    - ); +/** + * During an edit-room swap, `room:movie-updated` often arrives before `room:playback-changed`. + * Prefer the in-flight swap id until server playback confirms it. + */ +function resolveActiveRoomMovieId( + playbackMovieId: string | null, + swapMovieId: string | null, + persistedMovieId: string | null +): string | null { + if (swapMovieId !== null && swapMovieId.length > 0) { + return swapMovieId; + } + if (playbackMovieId !== null && playbackMovieId.length > 0) { + return playbackMovieId; + } + return persistedMovieId; } function playBeep() { @@ -153,68 +86,6 @@ function PencilIcon() { } -async function fetchRoom(id: string): Promise { - const path = getRoomContract.path.replace(':id', encodeURIComponent(id)); - const res = await fetch(`${API_BASE_URL}${path}`, { - headers: { Accept: 'application/json' }, - credentials: 'include' - }); - if (!res.ok) throw new Error(`HTTP ${String(res.status)}`); - return getRoomContract.responseSchema.parse(await res.json()); -} - -async function fetchRoomPreview(id: string): Promise { - const path = previewRoomContract.path.replace(':id', encodeURIComponent(id)); - const res = await fetch(`${API_BASE_URL}${path}`, { - headers: { Accept: 'application/json' }, - credentials: 'include' - }); - if (!res.ok) throw new Error(`HTTP ${String(res.status)}`); - return previewRoomContract.responseSchema.parse(await res.json()); -} - -async function joinRoom(id: string, password: string | undefined): Promise { - const path = joinRoomContract.path.replace(':id', encodeURIComponent(id)); - const body = joinRoomContract.bodySchema.parse(password !== undefined ? { password } : {}); - const res = await fetch(`${API_BASE_URL}${path}`, { - method: joinRoomContract.method, - headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - credentials: 'include', - body: JSON.stringify(body), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})) as Record; - throw new Error(typeof data['detail'] === 'string' ? data['detail'] : `HTTP ${String(res.status)}`); - } -} - -async function leaveRoom(id: string): Promise { - const path = leaveRoomContract.path.replace(':id', encodeURIComponent(id)); - const res = await fetch(`${API_BASE_URL}${path}`, { - method: leaveRoomContract.method, - headers: { Accept: 'application/json' }, - credentials: 'include', - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})) as Record; - throw new Error(typeof data['detail'] === 'string' ? data['detail'] : `HTTP ${String(res.status)}`); - } -} - -async function fetchCurrentUserId(): Promise { - try { - const res = await fetch(`${API_BASE_URL}${getAuthMeContract.path}`, { - headers: { Accept: 'application/json' }, - credentials: 'include' - }); - if (!res.ok) return null; - const me = getAuthMeContract.responseSchema.parse(await res.json()); - return me.userId; - } catch { - return null; - } -} - export function RoomPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -235,43 +106,211 @@ export function RoomPage() { const [volume, setVolume] = useState(80); const [muted, setMuted] = useState(false); const [videoReady, setVideoReady] = useState(false); + const [posterFrameReady, setPosterFrameReady] = useState(false); const [videoError, setVideoError] = useState(null); const [playbackRate, setPlaybackRate] = useState(1); const [ownerMovieSaving, setOwnerMovieSaving] = useState(false); - const [activeTab, setActiveTab] = useState('participants'); + const [activeTab, setActiveTab] = useState('chat'); const [unreadCount, setUnreadCount] = useState(0); const [chatSoundMuted, setChatSoundMuted] = useState(false); + const [chatDraft, setChatDraft] = useState(''); const [ownerUploadFile, setOwnerUploadFile] = useState(null); const [ownerUploadError, setOwnerUploadError] = useState(null); const [ownerUploadPercent, setOwnerUploadPercent] = useState(null); const [showRoomPassword, setShowRoomPassword] = useState(false); + const [forcePlayModalOpen, setForcePlayModalOpen] = useState(false); + const [remotePlaybackEvent, setRemotePlaybackEvent] = useState(null); + const [swapMovieId, setSwapMovieId] = useState(null); + const [movieChangePending, setMovieChangePending] = useState<{ movieName: string | null } | null>( + null + ); + const [movieStreamRevision, setMovieStreamRevision] = useState(0); const [countdownDismissed, setCountdownDismissed] = useState(false); + const [friendIds, setFriendIds] = useState(() => MOCK_FRIENDS.map((friend) => friend.id)); const videoRef = useRef(null); + const swapMovieIdRef = useRef(null); + const retriedStreamForSrcRef = useRef(null); + const suppressPlaybackEmitRef = useRef(false); + const currentTimeRef = useRef(0); + const lastProgressUiAtRef = useRef(0); + const PROGRESS_UI_MS = 250; + + const refreshRoom = useCallback(async () => { + if (id === undefined) return; + try { + const updated = await fetchRoom(id); + setRoom((prev) => { + if (prev === null) return updated; + const pendingSwap = swapMovieIdRef.current; + if ( + pendingSwap !== null && + updated.movie !== pendingSwap && + prev.movie === pendingSwap + ) { + return { ...updated, movie: pendingSwap }; + } + return updated; + }); + } catch (err: unknown) { + console.error('[room]', err instanceof Error ? err.message : 'Failed to refresh room'); + } + }, [id]); + + const handleMovieUpdated = useCallback((movieId: string, movieName?: string) => { + if (movieId.length === 0) return; + swapMovieIdRef.current = movieId; + setSwapMovieId(movieId); + setMovieStreamRevision((revision) => revision + 1); + setMovieChangePending({ movieName: movieName?.trim() ?? null }); + setCountdownDismissed(false); + setIsPlaying(false); + setCurrentTime(0); + setPosterFrameReady(false); + setVideoError(null); + videoRef.current?.pause(); + setRoom((prev) => { + if (prev === null) return prev; + const next = { ...prev, movie: movieId }; + if (movieName !== undefined && movieName.length > 0) { + return { ...next, movie_name: movieName }; + } + return next; + }); + void refreshRoom(); + }, [refreshRoom]); + + const handlePlaybackChanged = useCallback((event: PlaybackChangeEvent) => { + setRemotePlaybackEvent(event); + }, []); + + const handlePlaybackApplied = useCallback( + (result: { currentTime: number; isPlaying: boolean; playbackRate: PlaybackRate }) => { + setCurrentTime(result.currentTime); + setPlaybackRate(result.playbackRate); + setIsPlaying(result.isPlaying); + }, + [] + ); + + const handlePlaybackPlayFailed = useCallback(() => { + setVideoError('Playback failed. Check your connection and try again.'); + }, []); + + const handlePlaybackMovieMismatch = useCallback(() => { + void refreshRoom(); + }, [refreshRoom]); + + const handleRemotePlaybackHandled = useCallback(() => { + setRemotePlaybackEvent(null); + }, []); + + const { + messages, + members, + socketStatus, + roomError, + roomState, + sendMessage: socketSendMessage, + sendReadyUpdate, + sendPlaybackUpdate, + sendKickUser, + sendBlockUser, + connectionGeneration + } = useRoomSocket({ + roomId: id ?? '', + disabled: loading || room === null, + creatorId: room?.creator ?? '', + creatorName: room?.creator_name ?? undefined, + initialMemberIds: room?.allowed_users ?? [], + onMovieUpdated: handleMovieUpdated, + onPlaybackChanged: handlePlaybackChanged + }); + + const authoritativeMovieId = resolveActiveRoomMovieId( + roomState.playback.movieId, + swapMovieId, + room?.movie ?? null + ); + const roomMovieId = canCurrentUserAccessRoomMovie(currentUserId, room) ? authoritativeMovieId : null; + const ownerIsUploading = ownerMovieSaving || ownerUploadPercent !== null; + + useEffect(() => { + swapMovieIdRef.current = swapMovieId; + }, [swapMovieId]); + + useEffect(() => { + retriedStreamForSrcRef.current = null; + }, [roomMovieId, movieStreamRevision]); + + useEffect(() => { + if (swapMovieId === null) return; + if (roomState.playback.movieId === swapMovieId) { + swapMovieIdRef.current = null; + setSwapMovieId(null); + } + }, [swapMovieId, roomState.playback.movieId]); + + useEffect(() => { + if (roomState.playback.isPlaying || roomState.countdown.active) { + setMovieChangePending(null); + } + }, [roomState.playback.isPlaying, roomState.countdown.active]); const { + movie, loading: movieLoading, error: movieError, mediaSrc, isUploading: movieUploading, isPlayable: moviePlayable, isFailed: movieFailed - } = useRoomMovie(room?.movie ?? null); + } = useRoomMovie(roomMovieId, movieStreamRevision); const isOwner = currentUserId !== null && currentUserId === room?.creator; - const showCountdown = - room !== null && - room.status === 'ready' && - moviePlayable && - !isPlaying && - !countdownDismissed; - - const { messages, members, socketStatus, sendMessage: socketSendMessage } = useRoomSocket({ - roomId: id ?? '', - disabled: loading || room === null, - currentUserId, - creatorId: room?.creator ?? '', - creatorName: room?.creator_name ?? undefined, - initialMemberIds: room?.allowed_users ?? [] - }); + + const showMovieSwapOverlay = + movieChangePending !== null && + !roomState.playback.isPlaying && + !roomState.countdown.active && + roomState.playback.positionSec === 0 && + authoritativeMovieId === roomMovieId && + (mediaSrc !== null || movieLoading); + const awaitingHostMovieName = + movieChangePending?.movieName ?? movie?.name ?? room?.movie_name ?? null; + const showMovieChangeNotice = movieChangePending !== null; + + const displayMembers = members.map((member) => ({ + ...member, + isFriend: friendIds.includes(member.id) + })); + const currentMember = displayMembers.find((member) => member.id === currentUserId) ?? null; + const isSoloHost = isOwner && displayMembers.length === 1; + // The server is authoritative for room status (it already returns 'waiting' + // for an empty room). We only override to 'waiting' when the movie file is not + // yet streamable on this client — stream readiness is browser-side state the + // server cannot know about. + const liveRoomStatus: RoomStatus = moviePlayable ? roomState.status : 'waiting'; + const unreadyMembers = displayMembers.filter((member) => !member.isHost && !member.isReady); + const needsForcePlayConfirmation = isOwner && !isSoloHost && unreadyMembers.length > 0; + const showCountdown = roomState.countdown.active && !countdownDismissed; + const readinessMembers = displayMembers.filter((member) => !member.isHost); + const handleAddFriend = (member: Member) => { + setFriendIds((prev) => (prev.includes(member.id) ? prev : [...prev, member.id])); + }; + const handleTagUser = (member: Member) => { + setActiveTab('chat'); + setChatDraft((draft) => { + const prefix = draft.length > 0 && !draft.endsWith(' ') ? `${draft} ` : draft; + return `${prefix}@${member.username} `; + }); + }; + const handleKickUser = (member: Member) => { + if (!isOwner) return; + sendKickUser(member.id); + }; + const handleBlockUser = (member: Member) => { + if (!isOwner) return; + sendBlockUser(member.id); + }; const loadRoom = (roomId: string, cancelled: { current: boolean }) => { setLoading(true); @@ -325,6 +364,7 @@ export function RoomPage() { useEffect(() => { setVideoReady(false); + setPosterFrameReady(false); setVideoError(null); setIsPlaying(false); setCurrentTime(0); @@ -332,6 +372,10 @@ export function RoomPage() { setPlaybackRate(1); }, [mediaSrc]); + useEffect(() => { + setVideoError(null); + }, [roomMovieId]); + useEffect(() => { const video = videoRef.current; if (video === null) return; @@ -349,9 +393,38 @@ export function RoomPage() { useEffect(() => { setShowRoomPassword(false); + setForcePlayModalOpen(false); + setRemotePlaybackEvent(null); setCountdownDismissed(false); + swapMovieIdRef.current = null; + setSwapMovieId(null); }, [room?.id]); + useEffect(() => { + if (roomState.countdown.active && roomState.countdown.endsAt !== null) { + setCountdownDismissed(false); + } + }, [roomState.countdown.active, roomState.countdown.endsAt]); + + useRoomPlaybackSync({ + videoRef, + roomMovieId, + mediaSrc, + videoReady, + posterFrameReady, + isOwner, + currentUserId, + playback: roomState.playback, + countdown: roomState.countdown, + connectionGeneration, + remotePlaybackEvent, + suppressPlaybackEmitRef, + onApplied: handlePlaybackApplied, + onPlayFailed: handlePlaybackPlayFailed, + onMovieMismatch: handlePlaybackMovieMismatch, + onRemoteEventHandled: handleRemotePlaybackHandled + }); + const prevMessageCount = useRef(0); useEffect(() => { if (messages.length > prevMessageCount.current) { @@ -391,52 +464,22 @@ export function RoomPage() { if (passwordRequired && roomPreview !== null) { return ( -
    -
    -
    - 🔒 -

    - {roomPreview.name} -

    -

    - {roomPreview.has_password ? 'This room is password protected.' : 'This is a private room.'} -

    -
    - {passwordError !== null && ( -
    - {passwordError} -
    - )} - {roomPreview.has_password && ( - { setPasswordInput(e.target.value); setPasswordError(null); }} - onKeyDown={(e) => { if (e.key === 'Enter') void handleJoin(); }} - style={{ marginBottom: 12 }} - /> - )} - - -
    -
    + { + setPasswordInput(value); + setPasswordError(null); + }} + onJoin={() => { + void handleJoin(); + }} + onBack={() => { + void navigate('/rooms'); + }} + /> ); } @@ -449,11 +492,48 @@ export function RoomPage() { ); } - const canControlPlayback = moviePlayable && videoReady && videoError === null; + const canControlPlayback = isOwner && moviePlayable && videoReady && videoError === null; + const canStartPlayback = canControlPlayback && (liveRoomStatus === 'ready' || isSoloHost); + const broadcastPlaybackState = () => { + const video = videoRef.current; + const movieId = authoritativeMovieId; + if ( + suppressPlaybackEmitRef.current || + !isOwner || + movieId == null || + video === null || + videoError !== null || + !videoReady + ) { + return; + } + + sendPlaybackUpdate({ + movieId, + isPlaying: !video.paused && !video.ended, + positionSec: video.currentTime, + playbackRate: video.playbackRate, + force: true + }); + }; const syncVideoTime = () => { const video = videoRef.current; if (video === null) return; + currentTimeRef.current = video.currentTime; + const now = Date.now(); + if (now - lastProgressUiAtRef.current >= PROGRESS_UI_MS) { + lastProgressUiAtRef.current = now; + setCurrentTime(video.currentTime); + setIsPlaying(!video.paused && !video.ended); + } + }; + + const syncVideoMetadata = () => { + const video = videoRef.current; + if (video === null) return; + currentTimeRef.current = video.currentTime; + lastProgressUiAtRef.current = Date.now(); setCurrentTime(video.currentTime); setDuration(Number.isFinite(video.duration) ? video.duration : 0); setIsPlaying(!video.paused && !video.ended); @@ -462,29 +542,102 @@ export function RoomPage() { const togglePlay = () => { const video = videoRef.current; if (video === null || !canControlPlayback) return; - if (video.paused) { - void video.play().catch(() => { - setVideoError('Playback failed. Check your connection and try again.'); + if (video.paused || video.ended) { + if (!canStartPlayback) { + if (needsForcePlayConfirmation) { + setForcePlayModalOpen(true); + } + return; + } + if (authoritativeMovieId == null) return; + if (video.ended) { + video.currentTime = 0; + setCurrentTime(0); + } + sendPlaybackUpdate({ + movieId: authoritativeMovieId, + isPlaying: true, + positionSec: video.currentTime, + playbackRate: video.playbackRate }); + setIsPlaying(true); } else { + if (authoritativeMovieId == null) return; + sendPlaybackUpdate({ + movieId: authoritativeMovieId, + isPlaying: false, + positionSec: video.currentTime, + playbackRate: video.playbackRate + }); video.pause(); } }; + const cancelForcePlay = () => { + setForcePlayModalOpen(false); + }; + + const confirmForcePlay = () => { + const video = videoRef.current; + if (video === null || authoritativeMovieId == null || !canControlPlayback) return; + setForcePlayModalOpen(false); + if (video.ended) { + video.currentTime = 0; + setCurrentTime(0); + } + sendPlaybackUpdate({ + movieId: authoritativeMovieId, + isPlaying: true, + positionSec: video.currentTime, + playbackRate: video.playbackRate, + force: true + }); + setIsPlaying(true); + }; + const seekBy = (deltaSeconds: number) => { const video = videoRef.current; if (video === null || !canControlPlayback) return; const next = Math.min(Math.max(0, video.currentTime + deltaSeconds), video.duration || 0); video.currentTime = next; setCurrentTime(next); + broadcastPlaybackState(); }; const handleVideoError = () => { + const video = videoRef.current; + if (video !== null && video.error?.code === MediaError.MEDIA_ERR_ABORTED) { + return; + } + if ( + video !== null && + mediaSrc !== null && + video.currentSrc.length > 0 && + video.currentSrc !== mediaSrc + ) { + return; + } + if (mediaSrc !== null && retriedStreamForSrcRef.current !== mediaSrc) { + retriedStreamForSrcRef.current = mediaSrc; + setMovieStreamRevision((revision) => revision + 1); + return; + } setVideoError('Could not load the video. Refresh the page or re-upload the file.'); setVideoReady(false); + setPosterFrameReady(false); setIsPlaying(false); }; + const handleVideoLoadedData = () => { + const video = videoRef.current; + if (video === null) return; + setPosterFrameReady(true); + if (!roomState.playback.isPlaying && !roomState.countdown.active) { + video.currentTime = 0; + video.pause(); + } + }; + const clearOwnerUpload = () => { setOwnerUploadFile(null); setOwnerUploadError(null); @@ -539,18 +692,28 @@ export function RoomPage() { const video = videoRef.current; if (video !== null) video.currentTime = seconds; setCurrentTime(seconds); + broadcastPlaybackState(); }; - const playbackStatusText = moviePlayable - ? (isPlaying ? 'LIVE' : videoReady ? 'READY' : 'LOADING') - : movieUploading - ? 'UPLOADING' - : 'PREPARING'; + const changePlaybackRate = (rate: PlaybackRate) => { + const video = videoRef.current; + if (video !== null) { + video.playbackRate = rate; + } + setPlaybackRate(rate); + broadcastPlaybackState(); + }; + + const playbackStatusText = movieUploading + ? 'UPLOADING' + : roomStatusShortLabel(liveRoomStatus); const ownerPlaceholderText = isOwner ? 'Choose one of your recent videos or upload a new one to start this room.' : 'Ask the owner to upload a movie.'; const roomPassword = room.password ?? ''; const roomPasswordVisible = isOwner && roomPassword.length > 0 && showRoomPassword; + const readyCount = readinessMembers.filter((member) => member.isReady).length; + const liveViewerCount = displayMembers.length; const ownerMovieActions = isOwner && room.movie == null ? (
    = { - watching: 'WATCHING', - preparing: 'PREPARING', - ready: 'READY', - }; - const statusClass: Record = { - watching: 'badge badge-watching', - preparing: 'badge badge-preparing', - ready: 'badge badge-ready', - }; - return (
    {room.name} - {room.status !== undefined && ( - {statusLabel[room.status]} - )} +
    {isOwner && ( @@ -660,6 +810,14 @@ export function RoomPage() {
    + {showMovieChangeNotice && ( + + )} + {/* Main area */}
    {/* Video column */} @@ -670,32 +828,56 @@ export function RoomPage() { movieName={room.movie_name} statusText={playbackStatusText} isLive={moviePlayable && isPlaying} - loading={movieLoading} - error={movieError} + loading={movieLoading && !ownerIsUploading} + error={ownerIsUploading ? null : movieError} isUploading={movieUploading} isFailed={movieFailed} mediaSrc={mediaSrc} + videoKey={roomMovieId} videoRef={videoRef} videoReady={videoReady} - videoError={videoError} + videoError={ownerIsUploading ? null : videoError} canControl={canControlPlayback} + showHostControls={isOwner} currentTime={currentTime} duration={duration} isPlaying={isPlaying} playbackRate={playbackRate} muted={muted} volume={volume} + showAwaitingHostOverlay={showMovieSwapOverlay} + awaitingHostMovieName={awaitingHostMovieName} + awaitingHostLoading={showMovieSwapOverlay && !posterFrameReady && videoError === null} + isHostViewer={isOwner} onTogglePlay={togglePlay} onTimeUpdate={syncVideoTime} - onLoadedMetadata={syncVideoTime} + onLoadedMetadata={syncVideoMetadata} + onLoadedData={handleVideoLoadedData} onPlay={() => { setIsPlaying(true); }} onPause={() => { setIsPlaying(false); }} - onEnded={() => { setIsPlaying(false); }} + onEnded={() => { + const video = videoRef.current; + if (video === null || authoritativeMovieId == null || !isOwner) { + setIsPlaying(false); + return; + } + video.currentTime = 0; + sendPlaybackUpdate({ + movieId: authoritativeMovieId, + isPlaying: false, + positionSec: 0, + playbackRate: 1, + ended: true + }); + setIsPlaying(false); + setCurrentTime(0); + setPlaybackRate(1); + }} onCanPlay={() => { setVideoReady(true); setVideoError(null); }} onVideoError={handleVideoError} onScrub={scrubTo} onSeekBy={seekBy} - onPlaybackRateChange={setPlaybackRate} + onPlaybackRateChange={changePlaybackRate} onToggleMute={() => { setMuted((m) => !m); }} onVolumeChange={(next) => { setVolume(next); setMuted(false); }} ownerActions={ownerMovieActions} @@ -703,10 +885,19 @@ export function RoomPage() { /> {showCountdown && ( { setCountdownDismissed(true); }} /> )} +
    @@ -720,7 +911,7 @@ export function RoomPage() { }} > {/* Sidebar info bar — movie title + connection status only (room name is in the top header) */} - {(room.movie_name !== null && room.movie_name !== undefined) || socketStatus !== 'connected' ? ( + {(room.movie_name !== null && room.movie_name !== undefined) || socketStatus !== 'connected' || roomError !== null ? (
    {room.movie_name !== null && room.movie_name !== undefined && (

    @@ -732,6 +923,11 @@ export function RoomPage() { {socketStatus === 'error' ? 'Connection error' : socketStatus === 'connecting' ? 'Connecting…' : 'Disconnected'}

    )} + {roomError !== null && ( +

    + {roomError} +

    + )}
    ) : null} @@ -771,6 +967,46 @@ export function RoomPage() {
    )} + {!isOwner && room.movie != null && readinessMembers.length > 0 && ( +
    +
    +
    +

    + Room readiness +

    +

    + {readyCount}/{readinessMembers.length} ready +

    +
    +
    +
    +

    Your status

    +

    + {currentMember?.isReady ? 'Ready' : 'Not ready'} +

    +
    + +
    +
    +
    + )} + {/* Tabs: Participants | Chat */} - Viewers ({members.length}) + Viewers ({liveViewerCount}) @@ -813,15 +1049,28 @@ export function RoomPage() { value="participants" className="soft-scroll mt-1 min-h-0 flex-1 overflow-y-auto" > - - + + - + diff --git a/apps/frontend/src/rooms/room-status-badge.tsx b/apps/frontend/src/rooms/room-status-badge.tsx new file mode 100644 index 0000000..84e77b8 --- /dev/null +++ b/apps/frontend/src/rooms/room-status-badge.tsx @@ -0,0 +1,13 @@ +import type { RoomStatus } from '@repo/schemas/rooms'; + +import { Badge } from '@/components/ui/badge'; +import { ROOM_STATUS_DISPLAY } from '@/rooms/room-status-display'; + +export function RoomStatusBadge({ status }: { status: RoomStatus }) { + const cfg = ROOM_STATUS_DISPLAY[status]; + return ( + + {cfg.label} + + ); +} diff --git a/apps/frontend/src/rooms/room-status-display.ts b/apps/frontend/src/rooms/room-status-display.ts new file mode 100644 index 0000000..9e9606c --- /dev/null +++ b/apps/frontend/src/rooms/room-status-display.ts @@ -0,0 +1,32 @@ +import type { RoomStatus } from '@repo/schemas/rooms'; + +/** Labels and badge styling shared by the lobby cards and the in-room header. */ +export const ROOM_STATUS_DISPLAY: Record = { + waiting: { + label: 'WAITING', + className: + 'border-amber-500/40 bg-amber-500/20 font-mono text-[10px] tracking-widest text-amber-400', + }, + ready: { + label: 'READY TO WATCH', + className: + 'border-emerald-500/40 bg-emerald-500/20 font-mono text-[10px] tracking-widest text-emerald-400', + }, + watching: { + label: '● LIVE', + className: + 'border-red-500/40 bg-red-500/20 font-mono text-[10px] tracking-widest text-red-400', + }, +}; + +/** Short label for compact surfaces (e.g. video player chrome). */ +export function roomStatusShortLabel(status: RoomStatus): string { + switch (status) { + case 'watching': + return 'LIVE'; + case 'ready': + return 'READY'; + case 'waiting': + return 'WAITING'; + } +} diff --git a/apps/frontend/src/types/room.ts b/apps/frontend/src/types/room.ts index 2a28918..f23749d 100644 --- a/apps/frontend/src/types/room.ts +++ b/apps/frontend/src/types/room.ts @@ -8,6 +8,8 @@ export interface Member { username: string; avatarColor: string; isHost: boolean; + isReady: boolean; + isFriend: boolean; status: MemberStatus; } diff --git a/apps/frontend/src/utils/initials.ts b/apps/frontend/src/utils/initials.ts new file mode 100644 index 0000000..b022e1e --- /dev/null +++ b/apps/frontend/src/utils/initials.ts @@ -0,0 +1,9 @@ +/** Two-letter uppercase initials from a display name. */ +export function initials(name: string): string { + return name + .split(' ') + .map((word) => word[0] ?? '') + .join('') + .slice(0, 2) + .toUpperCase(); +} diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 41dc578..ae56fa8 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -8,7 +8,7 @@ Next.js 16 App Router server-rendered starter surface. Use for generic starter e ## Structure -- `src/app/layout.tsx` — Server Component: root layout. Loads Fraunces (display serif, italic, SOFT+WONK+opsz axes), Geist Sans (body), Geist Mono, IBM Plex Mono (mono/numerals) via `next/font`. +- `src/app/layout.tsx` — Server Component: root layout. Uses offline-safe serif/sans/mono fallbacks mapped through CSS variables in `globals.css`. - `src/app/page.tsx` — Server Component: Magazine-editorial layout (masthead → hero + by-the-numbers stats → endpoint explorer + session → health + pitch footer). Masthead links to `/app`. - `src/app/app/page.tsx` — Client Component: cookie session gate via `GET /api/auth/me` (`credentials: 'include'`); redirects to `/` on `401`. Editorial shell consistent with the home page. - `src/middleware.ts` — Edge middleware on **`/app`** (and nested paths): redirects to `/?login=1` when **neither** `access_token` nor `refresh_token` cookie is present. Cookie presence is not proof of a valid session; this only avoids rendering the shell before the client runs. @@ -17,15 +17,16 @@ Next.js 16 App Router server-rendered starter surface. Use for generic starter e - `src/app/notes-panel.tsx` — Client Component: full CRUD styled as a "Letters to the Editor" column (not mounted on the home page; kept for reuse). - `src/app/package-verification.tsx` — Client Component: runtime checks via static imports of each `@repo/*` package + live backend health. - `src/app/endpoint-explorer.tsx` — Client Component: lists contracts with "Try →". -- `src/app/globals.css` — Tailwind v4 via `@import 'tailwindcss'` + `@theme inline`. Editorial palette (`--color-paper`, `--color-ink`, `--color-rule`, `--color-accent`) + keyframes (`fade-up`, `rule-draw`, `check-in`, `dot-pulse`). +- `src/app/globals.css` — Tailwind v4 via `@import 'tailwindcss'`. Editorial CSS variables (`--color-paper`, `--color-ink`, `--color-rule`, `--color-accent`, font stacks) + keyframes (`fade-up`, `rule-draw`, `check-in`, `dot-pulse`). ## Conventions - Use the `@/` alias for local imports from `src/*`. - Server Components by default — push `"use client"` boundaries down. - React Compiler enabled — no manual memoization. -- Tailwind v4 via PostCSS (`@tailwindcss/postcss`) — no `tailwind.config.js`. Tokens live in `globals.css` under `@theme inline`. +- Tailwind v4 via PostCSS (`@tailwindcss/postcss`) — no `tailwind.config.js`. Tokens live in `globals.css` as CSS variables. - `@repo/*` packages are transpiled via `transpilePackages` in `next.config.ts`. +- `pnpm --filter web dev` uses `next dev --webpack` to avoid Turbopack persistence issues on this machine. - Visible copy lives in `@repo/consts/starter` (`STARTER_HEADLINE`, `STARTER_DECK`, `STARTER_LEDE`, `STARTER_STATS`, `STARTER_PITCH`). Update there, not inline. - Layout targets viewport-fit (100dvh × 100vw, no page scroll, desktop-only). Internal scroll allowed inside cards. - Aesthetic is editorial magazine — serif italic headlines, mono figures, hairline rules. Do not introduce rounded card chrome or gradients. diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index 547e408..5de2cec 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -7,14 +7,14 @@ ``` src/app/ - layout.tsx — Server Component: root layout, loads Fraunces + Geist + Geist_Mono + IBM_Plex_Mono via next/font + layout.tsx — Server Component: root layout, uses offline-safe serif/sans/mono fallbacks via CSS variables page.tsx — Server Component: magazine-editorial layout health-check.tsx — Client: auto-pings /api/health, dot + latency auth-panel.tsx — Client: register + email verify/resend + login (blocked until verified) + session, consts/schemas + cookies notes-panel.tsx — Client: full CRUD, styled as "Letters" column (optional; not on home page) package-verification.tsx — Client: static probes of every @repo/* export + live backend endpoint-explorer.tsx — Client: every EndpointContract with Try button + JSON drawer - globals.css — Tailwind v4 + @theme inline (editorial tokens + keyframes) + globals.css — Tailwind v4 (editorial CSS variables + keyframes) favicon.ico ``` @@ -35,14 +35,11 @@ src/app/ ## Fonts -Four Google fonts loaded via `next/font/google` in `layout.tsx`: +System font stacks are defined in `globals.css` as CSS variables: -- `Fraunces` (serif display + body, italic, SOFT + WONK + opsz axes) → `--font-serif` -- `Geist` (sans body) → `--font-sans` -- `Geist_Mono` → `--font-geist-mono` -- `IBM_Plex_Mono` (mono figures, labels, status glyphs) → `--font-mono` - -All mapped to Tailwind via `@theme inline` in `globals.css`. +- `--font-serif` → Georgia / Times stack +- `--font-sans` → system UI stack +- `--font-mono` and `--font-geist-mono` → monospace stack ## Tailwind v4 @@ -62,7 +59,7 @@ All mapped to Tailwind via `@theme inline` in `globals.css`. - `NEXT_PUBLIC_API_BASE_URL` — defined in `.env` examples but not yet wired into call sites; today every `fetch` uses the hardcoded `API_BASE_URL` from `@repo/consts/api`. - `NEXT_PUBLIC_FRONTEND_URL` — defined in `.env` examples, not used in code. -- Port is pinned to `5172` via `cross-env PORT=5172` in `dev`, `start`, `preview`, and `start:prod` in `package.json` (override with `PORT` in the host env if your platform requires it). +- Port is pinned to `5172` via `cross-env PORT=5172` in `dev`, `start`, `preview`, and `start:prod` in `package.json` (override with `PORT` in the host env if your platform requires it). `dev` uses `next dev --webpack` to avoid Turbopack persistence failures locally. ## Commands diff --git a/apps/web/package.json b/apps/web/package.json index afb5ea2..0ca5b09 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "cross-env PORT=5172 next dev", + "dev": "cross-env PORT=5172 next dev --webpack", "build": "next build", "check-types": "tsc --noEmit", "lint": "eslint . --max-warnings 0", diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 3e6095c..e8a17a6 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -10,22 +10,10 @@ --color-ok: #1d6a3a; --color-warn: #a67212; --color-fail: #b8321a; -} - -@theme inline { - --color-paper: var(--color-paper); - --color-ink: var(--color-ink); - --color-rule: var(--color-rule); - --color-mute: var(--color-mute); - --color-accent: var(--color-accent); - --color-accent-soft: var(--color-accent-soft); - --color-ok: var(--color-ok); - --color-warn: var(--color-warn); - --color-fail: var(--color-fail); - --font-serif: var(--font-serif); - --font-sans: var(--font-sans); - --font-mono: var(--font-mono); - --font-geist-mono: var(--font-geist-mono); + --font-serif: Georgia, 'Times New Roman', serif; + --font-sans: Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-mono: 'SFMono-Regular', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', monospace; + --font-geist-mono: 'SFMono-Regular', ui-monospace, 'Cascadia Mono', 'Segoe UI Mono', monospace; } html, diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index b0c128d..4cfc63d 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,5 +1,4 @@ import type { Metadata } from 'next'; -import { Fraunces, Geist, Geist_Mono, IBM_Plex_Mono } from 'next/font/google'; import { STARTER_META_DESCRIPTION, STARTER_NAME, @@ -7,29 +6,6 @@ import { } from '@repo/consts/starter'; import './globals.css'; -const geistSans = Geist({ - variable: '--font-sans', - subsets: ['latin'] -}); - -const geistMono = Geist_Mono({ - variable: '--font-geist-mono', - subsets: ['latin'] -}); - -const fraunces = Fraunces({ - variable: '--font-serif', - subsets: ['latin'], - axes: ['SOFT', 'WONK', 'opsz'], - style: ['normal', 'italic'] -}); - -const plexMono = IBM_Plex_Mono({ - variable: '--font-mono', - subsets: ['latin'], - weight: ['300', '400', '500', '600'] -}); - export const metadata: Metadata = { title: `${STARTER_NAME} — ${STARTER_TAGLINE}`, description: STARTER_META_DESCRIPTION @@ -41,10 +17,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + {children} ); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 9574f80..a375cf4 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -10,8 +10,6 @@ "next-env.d.ts", "**/*.ts", "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts", "**/*.mts" ], "exclude": ["node_modules"] diff --git a/packages/consts/package.json b/packages/consts/package.json index cf5fbb3..864309c 100644 --- a/packages/consts/package.json +++ b/packages/consts/package.json @@ -10,7 +10,8 @@ "./starter": "./dist/starter/starter.consts.js", "./profile": "./dist/profile/profile.consts.js", "./rooms": "./dist/rooms/rooms.consts.js", - "./movies": "./dist/movies/movies.consts.js" + "./movies": "./dist/movies/movies.consts.js", + "./realtime": "./dist/realtime/realtime.consts.js" }, "scripts": { "dev": "tsc --watch --preserveWatchOutput", diff --git a/packages/consts/src/realtime/realtime.consts.ts b/packages/consts/src/realtime/realtime.consts.ts new file mode 100644 index 0000000..1fa385c --- /dev/null +++ b/packages/consts/src/realtime/realtime.consts.ts @@ -0,0 +1,31 @@ +// Socket.IO event names shared by the backend gateway and frontend clients. +// Keeping them here prevents the two sides from drifting on a typo'd literal. + +export const REALTIME_CLIENT_EVENTS = { + join: 'room:join', + leave: 'room:leave', + message: 'room:message', + movieUpdated: 'room:movie-updated', + playbackUpdate: 'room:playback-update', + readyUpdate: 'room:ready-update', + kickUser: 'room:kick-user', + blockUser: 'room:block-user' +} as const; + +export const REALTIME_SERVER_EVENTS = { + connectionAck: 'connection:ack', + roomState: 'room:state', + presenceChanged: 'room:presence-changed', + userJoined: 'room:user-joined', + userLeft: 'room:user-left', + messageReceived: 'room:message-received', + movieUpdated: 'room:movie-updated', + playbackChanged: 'room:playback-changed', + error: 'room:error' +} as const; + +export type RealtimeClientEvent = + (typeof REALTIME_CLIENT_EVENTS)[keyof typeof REALTIME_CLIENT_EVENTS]; + +export type RealtimeServerEvent = + (typeof REALTIME_SERVER_EVENTS)[keyof typeof REALTIME_SERVER_EVENTS]; diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 0944289..076fcc1 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -9,7 +9,8 @@ "./shared": "./dist/shared/endpoint.js", "./profile": "./dist/profile/profile.contracts.js", "./rooms": "./dist/rooms/rooms.contracts.js", - "./movies": "./dist/movies/movies.contracts.js" + "./movies": "./dist/movies/movies.contracts.js", + "./realtime": "./dist/realtime/realtime.contracts.js" }, "scripts": { "dev": "tsc --watch --preserveWatchOutput", diff --git a/packages/contracts/src/realtime/realtime.contracts.ts b/packages/contracts/src/realtime/realtime.contracts.ts new file mode 100644 index 0000000..0494bf0 --- /dev/null +++ b/packages/contracts/src/realtime/realtime.contracts.ts @@ -0,0 +1,154 @@ +import { REALTIME_CLIENT_EVENTS, REALTIME_SERVER_EVENTS } from '@repo/consts/realtime'; +import { + joinRoomPayloadSchema, + leaveRoomPayloadSchema, + realtimeChatMessageSchema, + roomErrorEventSchema, + roomModerateUserPayloadSchema, + roomMovieUpdatedEventSchema, + roomMovieUpdatedPayloadSchema, + roomPlaybackChangedEventSchema, + roomPlaybackUpdatePayloadSchema, + roomPresenceChangedEventSchema, + roomReadyUpdatePayloadSchema, + roomStateEventSchema, + sendMessagePayloadSchema, + userJoinedEventSchema, + userLeftEventSchema, + type JoinRoomPayload, + type LeaveRoomPayload, + type RealtimeChatMessage, + type RoomErrorEvent, + type RoomModerateUserPayload, + type RoomMovieUpdatedEvent, + type RoomMovieUpdatedPayload, + type RoomPlaybackChangedEvent, + type RoomPlaybackUpdatePayload, + type RoomPresenceChangedEvent, + type RoomReadyUpdatePayload, + type RoomStateEvent, + type SendMessagePayload, + type UserJoinedEvent, + type UserLeftEvent +} from '@repo/schemas/realtime'; +import type { z } from 'zod'; + +export type SocketEventDirection = 'client-to-server' | 'server-to-client'; + +/** + * The socket equivalent of {@link EndpointContract}: it ties an event name to + * its direction and the Zod schema that validates its payload, so both sides of + * the wire validate against the same source of truth. + */ +export type SocketEventContract = { + event: string; + direction: SocketEventDirection; + payloadSchema: z.ZodType; +}; + +export type SocketEventPayload = + C extends SocketEventContract ? P : never; + +// --------------------------------------------------------------------------- +// Client -> Server +// --------------------------------------------------------------------------- + +export const joinRoomContract: SocketEventContract = { + event: REALTIME_CLIENT_EVENTS.join, + direction: 'client-to-server', + payloadSchema: joinRoomPayloadSchema +}; + +export const leaveRoomContract: SocketEventContract = { + event: REALTIME_CLIENT_EVENTS.leave, + direction: 'client-to-server', + payloadSchema: leaveRoomPayloadSchema +}; + +export const sendMessageContract: SocketEventContract = { + event: REALTIME_CLIENT_EVENTS.message, + direction: 'client-to-server', + payloadSchema: sendMessagePayloadSchema +}; + +export const movieUpdatedRequestContract: SocketEventContract = { + event: REALTIME_CLIENT_EVENTS.movieUpdated, + direction: 'client-to-server', + payloadSchema: roomMovieUpdatedPayloadSchema +}; + +export const playbackUpdateContract: SocketEventContract = { + event: REALTIME_CLIENT_EVENTS.playbackUpdate, + direction: 'client-to-server', + payloadSchema: roomPlaybackUpdatePayloadSchema +}; + +export const readyUpdateContract: SocketEventContract = { + event: REALTIME_CLIENT_EVENTS.readyUpdate, + direction: 'client-to-server', + payloadSchema: roomReadyUpdatePayloadSchema +}; + +export const kickUserContract: SocketEventContract = { + event: REALTIME_CLIENT_EVENTS.kickUser, + direction: 'client-to-server', + payloadSchema: roomModerateUserPayloadSchema +}; + +export const blockUserContract: SocketEventContract = { + event: REALTIME_CLIENT_EVENTS.blockUser, + direction: 'client-to-server', + payloadSchema: roomModerateUserPayloadSchema +}; + +// --------------------------------------------------------------------------- +// Server -> Client +// --------------------------------------------------------------------------- + +export const roomStateContract: SocketEventContract = { + event: REALTIME_SERVER_EVENTS.roomState, + direction: 'server-to-client', + payloadSchema: roomStateEventSchema +}; + +export const presenceChangedContract: SocketEventContract = { + event: REALTIME_SERVER_EVENTS.presenceChanged, + direction: 'server-to-client', + payloadSchema: roomPresenceChangedEventSchema +}; + +export const userJoinedContract: SocketEventContract = { + event: REALTIME_SERVER_EVENTS.userJoined, + direction: 'server-to-client', + payloadSchema: userJoinedEventSchema +}; + +export const userLeftContract: SocketEventContract = { + event: REALTIME_SERVER_EVENTS.userLeft, + direction: 'server-to-client', + payloadSchema: userLeftEventSchema +}; + +export const messageReceivedContract: SocketEventContract = { + event: REALTIME_SERVER_EVENTS.messageReceived, + direction: 'server-to-client', + payloadSchema: realtimeChatMessageSchema +}; + +export const movieUpdatedEventContract: SocketEventContract = { + event: REALTIME_SERVER_EVENTS.movieUpdated, + direction: 'server-to-client', + payloadSchema: roomMovieUpdatedEventSchema +}; + +export const playbackChangedContract: SocketEventContract = { + event: REALTIME_SERVER_EVENTS.playbackChanged, + direction: 'server-to-client', + payloadSchema: roomPlaybackChangedEventSchema +}; + +export const roomErrorContract: SocketEventContract = { + event: REALTIME_SERVER_EVENTS.error, + direction: 'server-to-client', + payloadSchema: roomErrorEventSchema +}; diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 4b6ab43..ffcdbb8 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -10,7 +10,8 @@ "./movies": "./dist/movies/movie.schemas.js", "./rooms": "./dist/rooms/room.schemas.js", "./profile": "./dist/profile/profile.schemas.js", - "./realtime": "./dist/realtime/realtime.schemas.js" + "./realtime": "./dist/realtime/realtime.schemas.js", + "./realtime/playback-sync": "./dist/realtime/playback-sync.js" }, "scripts": { "dev": "tsc --watch --preserveWatchOutput", diff --git a/packages/schemas/src/realtime/playback-sync.ts b/packages/schemas/src/realtime/playback-sync.ts new file mode 100644 index 0000000..81d3b29 --- /dev/null +++ b/packages/schemas/src/realtime/playback-sync.ts @@ -0,0 +1,43 @@ +import type { PlaybackState } from './realtime.schemas.js'; + +/** Drift beyond this (seconds) triggers a seek when applying server playback. */ +export const PLAYBACK_DRIFT_THRESHOLD_SEC = 2; + +/** Tighter threshold for first snapshot after connect/rejoin. */ +export const PLAYBACK_JOIN_DRIFT_THRESHOLD_SEC = 0.3; + +/** Materialized position drift that unfreezes playback from room:state. */ +export const PLAYBACK_UNFREEZE_DRIFT_SEC = 0.5; + +/** Live position the host (server) is at, accounting for elapsed play time. */ +export function getMaterializedPlaybackPosition(playback: PlaybackState, atMs = Date.now()): number { + if (!playback.isPlaying) { + return playback.positionSec; + } + const elapsedSeconds = Math.max(0, atMs - new Date(playback.updatedAt).getTime()) / 1000; + return playback.positionSec + elapsedSeconds * playback.playbackRate; +} + +/** Whether a room:state payload should override frozen playback on the client. */ +export function shouldUnfreezePlaybackFromRoomState( + prevPlayback: PlaybackState, + nextPlayback: PlaybackState, + prevCountdownActive: boolean, + nextCountdownActive: boolean +): boolean { + if (prevPlayback.movieId !== nextPlayback.movieId) { + return true; + } + if (prevCountdownActive && !nextCountdownActive) { + return true; + } + if (prevPlayback.isPlaying !== nextPlayback.isPlaying) { + return true; + } + if (prevPlayback.updatedAt !== nextPlayback.updatedAt) { + const prevPos = getMaterializedPlaybackPosition(prevPlayback); + const nextPos = getMaterializedPlaybackPosition(nextPlayback); + return Math.abs(prevPos - nextPos) > PLAYBACK_UNFREEZE_DRIFT_SEC; + } + return false; +} diff --git a/packages/schemas/src/realtime/realtime.schemas.ts b/packages/schemas/src/realtime/realtime.schemas.ts index e3acbb3..6083be1 100644 --- a/packages/schemas/src/realtime/realtime.schemas.ts +++ b/packages/schemas/src/realtime/realtime.schemas.ts @@ -1,15 +1,22 @@ import { z } from 'zod'; +import { roomStatusSchema } from '../rooms/room.schemas.js'; + // --------------------------------------------------------------------------- // Leaf schemas // --------------------------------------------------------------------------- +// Re-exported so realtime consumers can keep importing the room status enum +// from a single place without duplicating the literal union. +export { roomStatusSchema }; + export const connectedUserSchema = z.object({ userId: z.string(), - socketId: z.string(), + socketIds: z.array(z.string()).min(1), joinedAt: z.string().datetime(), userName: z.string(), - color: z.string() + color: z.string(), + isReady: z.boolean() }); export type ConnectedUser = z.infer; @@ -26,26 +33,35 @@ export const realtimeChatMessageSchema = z.object({ export type RealtimeChatMessage = z.infer; -/** Schema only — no playback events are wired yet. */ export const playbackStateSchema = z.object({ movieId: z.string().nullable(), isPlaying: z.boolean(), positionSec: z.number().nonnegative(), + playbackRate: z.number().positive(), updatedAt: z.string().datetime() }); export type PlaybackState = z.infer; +export const countdownStateSchema = z.object({ + active: z.boolean(), + endsAt: z.string().datetime().nullable() +}); + +export type CountdownState = z.infer; + // --------------------------------------------------------------------------- // In-memory room state (backend only — never persisted to MongoDB) // --------------------------------------------------------------------------- export const realtimeRoomStateSchema = z.object({ roomId: z.string(), + status: roomStatusSchema, connectedUsers: z.array(connectedUserSchema), /** Capped at the last 100 messages. */ messages: z.array(realtimeChatMessageSchema), - playback: playbackStateSchema + playback: playbackStateSchema, + countdown: countdownStateSchema }); export type RealtimeRoomState = z.infer; @@ -73,6 +89,40 @@ export const sendMessagePayloadSchema = z.strictObject({ export type SendMessagePayload = z.infer; +export const roomMovieUpdatedPayloadSchema = z.strictObject({ + roomId: z.string().min(1), + movieId: z.string().min(1) +}); + +export type RoomMovieUpdatedPayload = z.infer; + +export const roomPlaybackUpdatePayloadSchema = z.strictObject({ + roomId: z.string().min(1), + movieId: z.string().min(1), + isPlaying: z.boolean(), + positionSec: z.number().nonnegative(), + playbackRate: z.number().positive(), + force: z.boolean().optional(), + /** When true with isPlaying:false, resets the watch session (position 0, all unready). */ + ended: z.boolean().optional() +}); + +export type RoomPlaybackUpdatePayload = z.infer; + +export const roomReadyUpdatePayloadSchema = z.strictObject({ + roomId: z.string().min(1), + isReady: z.boolean() +}); + +export type RoomReadyUpdatePayload = z.infer; + +export const roomModerateUserPayloadSchema = z.strictObject({ + roomId: z.string().min(1), + targetUserId: z.string().min(1) +}); + +export type RoomModerateUserPayload = z.infer; + // --------------------------------------------------------------------------- // Server → Client payloads // --------------------------------------------------------------------------- @@ -81,19 +131,48 @@ export const userJoinedEventSchema = z.object({ userId: z.string(), roomId: z.string(), userName: z.string(), - color: z.string() + color: z.string(), + isReady: z.boolean() }); export type UserJoinedEvent = z.infer; export const roomStateEventSchema = z.object({ + status: roomStatusSchema, connectedUsers: z.array(connectedUserSchema), messages: z.array(realtimeChatMessageSchema), - playback: playbackStateSchema + playback: playbackStateSchema, + countdown: countdownStateSchema }); export type RoomStateEvent = z.infer; +/** Slim presence update — no playback or chat history (avoids join/leave stutter). */ +export const roomPresenceChangedEventSchema = z.object({ + roomId: z.string(), + status: roomStatusSchema, + connectedUsers: z.array(connectedUserSchema), + countdown: countdownStateSchema +}); + +export type RoomPresenceChangedEvent = z.infer; + +export const roomMovieUpdatedEventSchema = z.strictObject({ + roomId: z.string(), + movieId: z.string(), + movieName: z.string().optional() +}); + +export type RoomMovieUpdatedEvent = z.infer; + +export const roomPlaybackChangedEventSchema = z.strictObject({ + roomId: z.string(), + actorUserId: z.string().nullable(), + playback: playbackStateSchema +}); + +export type RoomPlaybackChangedEvent = z.infer; + export const userLeftEventSchema = z.object({ userId: z.string(), roomId: z.string() diff --git a/packages/schemas/src/rooms/room.schemas.ts b/packages/schemas/src/rooms/room.schemas.ts index 5d6cd14..b3278ea 100644 --- a/packages/schemas/src/rooms/room.schemas.ts +++ b/packages/schemas/src/rooms/room.schemas.ts @@ -19,7 +19,7 @@ export const createRoomSchema = z.strictObject({ export type CreateRoomInput = z.infer; -export const roomStatusSchema = z.enum(['watching', 'preparing', 'ready']); +export const roomStatusSchema = z.enum(['waiting', 'ready', 'watching']); export type RoomStatus = z.infer;