Files
silo-server/web/src/lib/watchTogether.ts
b250dbb59b fix(watchtogether): harden realtime sync, room lifecycle, and lobby UX (#273)
* fix(watchtogether): harden realtime sync, room lifecycle, and lobby UX

Remediates all findings from a deep review of the Watch Together feature.

Server:
- Serialize every websocket write (pong/error replies bypassed the write
  mutex, racing broadcasts on the same gorilla conn)
- Send room_closed with a reason on terminal connect failures so clients
  stop reconnecting to dead rooms
- Persist room state outside the service-wide mutex via a shared
  generation-CAS helper; drop ~450 lines of dead duplicated methods
- Measure transport latency from server-side ping/pong RTT (was one-way
  client-clock delta, poisoned by clock skew) and clamp the lead time
- Re-evaluate readiness when a waiting participant disconnects and add a
  30s waiting deadline that skips stragglers (activates ignoreWait)
- Guard the host-disconnect close timer against reconnect races
- Clamp buffering-report anchor moves; clear stale member sessions on
  selection change
- Janitor: evict empty live rooms and close rooms idle >24h
- Snapshot gains an additive members list with profile display names

Web:
- Surface terminal room errors (REST 404/410/403 and WS error codes) as
  closedReason instead of reconnecting forever on "Connecting..."
- Memoize the playback-sync hook and narrow VideoPlayer's video-listener
  effect deps to stop re-subscribing 13 listeners on every render
- Preserve invite-link destination through login/profile guards
- Lobby: terminal ended/missing-token states with CTAs, End-room confirm
  dialog, toast feedback via shared action helpers (dedup with player),
  participant list with guest Leave, mobile-visible connection status,
  document title, a11y labels/focus reveal, unified status dot component
- Delete dead useWatchTogetherRoom hook (345 lines, zero importers)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(watchtogether): guard indexed access in join-page keyboard nav for noUncheckedIndexedAccess

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(watchtogether): reconcile CAS conflicts without regressing newer state; roll back unpersisted waiting-resume

Addresses Codex review on PR #273:
- persistRoomChangeLocked now undoes the failed writer's optimistic
  generation increment and only adopts the refreshed database row when it
  is at least as new as the local copy, so a stale conflict refresh can
  no longer overwrite a concurrent writer's newer in-memory state (and a
  failed write can no longer leave a phantom generation)
- maybeResumeFromWaitingLocked restores the waiting state and re-arms the
  deadline when the resume transition fails to persist, instead of
  broadcasting a resume the database never recorded

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:34:02 -04:00

241 lines
6.5 KiB
TypeScript

import { api } from "@/api/client";
export type GuestControlPolicy = "host_only" | "guest_play_pause";
export type WatchTogetherRole = "host" | "guest";
export type WatchTogetherRoomPhase = "lobby" | "playing" | "ended";
export type WatchTogetherPlaybackState = "idle" | "waiting" | "paused" | "playing";
export type WatchTogetherSelectionMode = "host_pick" | "vote";
export type WatchTogetherTransportAction = "play" | "pause" | "seek";
export interface WatchTogetherRoomMember {
user_id: number;
profile_id: string;
display_name: string;
is_host: boolean;
is_self: boolean;
connected: boolean;
}
export interface WatchTogetherRoomSnapshot {
room_id: string;
phase: WatchTogetherRoomPhase;
playback_state: WatchTogetherPlaybackState;
selection_mode: WatchTogetherSelectionMode;
selection_revision: number;
selected_content_id?: string;
selected_file_id?: number;
selected_library_id?: number;
code: string;
guest_control_policy: GuestControlPolicy;
is_paused: boolean;
anchor_position_seconds: number;
anchor_updated_at: string;
generation: number;
member_count: number;
host_connected: boolean;
self_role: WatchTogetherRole;
self_can_control_transport: boolean;
self_can_manage_room: boolean;
self_ignore_wait: boolean;
attached_session_id?: string;
invite_path?: string;
/** Optional additive field; absent on older servers. Host is listed first. */
members?: WatchTogetherRoomMember[];
}
export interface WatchTogetherTransportCommand {
command_id: string;
session_id?: string;
selection_revision: number;
action: WatchTogetherTransportAction;
position_seconds: number;
execute_at: string;
issued_at: string;
playback_state: WatchTogetherPlaybackState;
}
export interface WatchTogetherRoomResponse {
room: WatchTogetherRoomSnapshot;
room_access_token?: string;
}
export interface CreateWatchTogetherRoomInput {
file_id?: number;
library_id?: number;
selection_mode?: WatchTogetherSelectionMode;
}
export interface WatchTogetherSuggestion {
id: string;
room_id: string;
suggester_user_id: number;
suggester_profile_id: string;
content_id: string;
content_type: "movie" | "episode";
title: string;
subtitle: string;
poster_url: string;
note: string;
vote_count: number;
voted_by_me: boolean;
created_at: string;
}
export interface WatchTogetherSuggestionsResponse {
suggestions: WatchTogetherSuggestion[];
}
export interface CreateWatchTogetherSuggestionInput {
content_id: string;
content_type: "movie" | "episode";
title: string;
subtitle?: string;
poster_url?: string;
note?: string;
}
export interface JoinWatchTogetherRoomInput {
code?: string;
join_token?: string;
}
export interface SelectWatchTogetherRoomItemInput {
content_id: string;
file_id?: number;
library_id?: number;
}
export async function createWatchTogetherRoom(input: CreateWatchTogetherRoomInput = {}) {
return api<WatchTogetherRoomResponse>("/watch-together/rooms", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function joinWatchTogetherRoom(input: JoinWatchTogetherRoomInput) {
return api<WatchTogetherRoomResponse>("/watch-together/join", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function getWatchTogetherRoom(roomId: string, roomToken: string) {
const params = new URLSearchParams({ room_token: roomToken });
return api<WatchTogetherRoomResponse>(`/watch-together/rooms/${roomId}?${params.toString()}`);
}
export async function updateWatchTogetherRoomPolicy(
roomId: string,
guestControlPolicy: GuestControlPolicy,
) {
return api<WatchTogetherRoomResponse>(`/watch-together/rooms/${roomId}/policy`, {
method: "PATCH",
body: JSON.stringify({ guest_control_policy: guestControlPolicy }),
});
}
export async function selectWatchTogetherRoomItem(
roomId: string,
input: SelectWatchTogetherRoomItemInput,
) {
return api<WatchTogetherRoomResponse>(`/watch-together/rooms/${roomId}/selection`, {
method: "PUT",
body: JSON.stringify(input),
});
}
export async function closeWatchTogetherRoom(roomId: string) {
return api<void>(`/watch-together/rooms/${roomId}`, {
method: "DELETE",
});
}
export async function listWatchTogetherSuggestions(roomId: string, roomToken: string) {
const params = new URLSearchParams({ room_token: roomToken });
return api<WatchTogetherSuggestionsResponse>(
`/watch-together/rooms/${roomId}/suggestions?${params.toString()}`,
);
}
export async function createWatchTogetherSuggestion(
roomId: string,
roomToken: string,
input: CreateWatchTogetherSuggestionInput,
) {
const params = new URLSearchParams({ room_token: roomToken });
return api<WatchTogetherSuggestionsResponse>(
`/watch-together/rooms/${roomId}/suggestions?${params.toString()}`,
{
method: "POST",
body: JSON.stringify(input),
},
);
}
export async function deleteWatchTogetherSuggestion(
roomId: string,
roomToken: string,
suggestionId: string,
) {
const params = new URLSearchParams({ room_token: roomToken });
return api<WatchTogetherSuggestionsResponse>(
`/watch-together/rooms/${roomId}/suggestions/${suggestionId}?${params.toString()}`,
{
method: "DELETE",
},
);
}
export async function voteWatchTogetherSuggestion(
roomId: string,
roomToken: string,
suggestionId: string,
) {
const params = new URLSearchParams({ room_token: roomToken });
return api<WatchTogetherSuggestionsResponse>(
`/watch-together/rooms/${roomId}/suggestions/${suggestionId}/vote?${params.toString()}`,
{
method: "POST",
},
);
}
export async function unvoteWatchTogetherSuggestion(
roomId: string,
roomToken: string,
suggestionId: string,
) {
const params = new URLSearchParams({ room_token: roomToken });
return api<WatchTogetherSuggestionsResponse>(
`/watch-together/rooms/${roomId}/suggestions/${suggestionId}/vote?${params.toString()}`,
{
method: "DELETE",
},
);
}
export async function promoteWatchTogetherSuggestion(
roomId: string,
roomToken: string,
suggestionId: string,
) {
const params = new URLSearchParams({ room_token: roomToken });
return api<WatchTogetherRoomResponse>(
`/watch-together/rooms/${roomId}/suggestions/promote?${params.toString()}`,
{
method: "POST",
body: JSON.stringify({ suggestion_id: suggestionId }),
},
);
}
export function buildWatchTogetherInviteUrl(invitePath?: string | null) {
if (!invitePath) {
return null;
}
if (typeof window === "undefined") {
return invitePath;
}
return new URL(invitePath, window.location.origin).toString();
}