* 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>
241 lines
6.5 KiB
TypeScript
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();
|
|
}
|