* 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>
199 lines
6.2 KiB
Go
199 lines
6.2 KiB
Go
package watchtogether
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
)
|
|
|
|
type GuestControlPolicy string
|
|
|
|
const (
|
|
GuestControlPolicyHostOnly GuestControlPolicy = "host_only"
|
|
GuestControlPolicyGuestPlayPause GuestControlPolicy = "guest_play_pause"
|
|
)
|
|
|
|
type RoomPhase string
|
|
|
|
const (
|
|
RoomPhaseLobby RoomPhase = "lobby"
|
|
RoomPhasePlaying RoomPhase = "playing"
|
|
RoomPhaseEnded RoomPhase = "ended"
|
|
)
|
|
|
|
type RoomPlaybackState string
|
|
|
|
const (
|
|
RoomPlaybackStateIdle RoomPlaybackState = "idle"
|
|
RoomPlaybackStateWaiting RoomPlaybackState = "waiting"
|
|
RoomPlaybackStatePaused RoomPlaybackState = "paused"
|
|
RoomPlaybackStatePlaying RoomPlaybackState = "playing"
|
|
)
|
|
|
|
type RoomSelectionMode string
|
|
|
|
const (
|
|
RoomSelectionModeHostPick RoomSelectionMode = "host_pick"
|
|
RoomSelectionModeVote RoomSelectionMode = "vote"
|
|
)
|
|
|
|
type MemberRole string
|
|
|
|
const (
|
|
MemberRoleHost MemberRole = "host"
|
|
MemberRoleGuest MemberRole = "guest"
|
|
)
|
|
|
|
type TransportAction string
|
|
|
|
const (
|
|
TransportActionPlay TransportAction = "play"
|
|
TransportActionPause TransportAction = "pause"
|
|
TransportActionSeek TransportAction = "seek"
|
|
)
|
|
|
|
type Room struct {
|
|
ID string
|
|
Code string
|
|
JoinToken string
|
|
HostUserID int
|
|
HostProfileID string
|
|
Phase RoomPhase
|
|
PlaybackState RoomPlaybackState
|
|
ResumeOnReady bool
|
|
SelectionMode RoomSelectionMode
|
|
SelectionRevision int64
|
|
SelectedContentID *string
|
|
SelectedFileID *int
|
|
SelectedLibraryID *int
|
|
GuestControlPolicy GuestControlPolicy
|
|
AnchorPositionSeconds float64
|
|
IsPaused bool
|
|
AnchorUpdatedAt time.Time
|
|
Generation int64
|
|
CreatedAt time.Time
|
|
ClosedAt *time.Time
|
|
}
|
|
|
|
// MemberSummary describes one connected room member in a snapshot.
|
|
type MemberSummary struct {
|
|
UserID int `json:"user_id"`
|
|
ProfileID string `json:"profile_id"`
|
|
DisplayName string `json:"display_name"`
|
|
IsHost bool `json:"is_host"`
|
|
IsSelf bool `json:"is_self"`
|
|
Connected bool `json:"connected"`
|
|
}
|
|
|
|
type Snapshot struct {
|
|
RoomID string `json:"room_id"`
|
|
Phase RoomPhase `json:"phase"`
|
|
PlaybackState RoomPlaybackState `json:"playback_state"`
|
|
SelectionMode RoomSelectionMode `json:"selection_mode"`
|
|
SelectionRevision int64 `json:"selection_revision"`
|
|
SelectedContentID *string `json:"selected_content_id,omitempty"`
|
|
SelectedFileID *int `json:"selected_file_id,omitempty"`
|
|
SelectedLibraryID *int `json:"selected_library_id,omitempty"`
|
|
Code string `json:"code"`
|
|
GuestControlPolicy GuestControlPolicy `json:"guest_control_policy"`
|
|
IsPaused bool `json:"is_paused"`
|
|
AnchorPositionSeconds float64 `json:"anchor_position_seconds"`
|
|
AnchorUpdatedAt string `json:"anchor_updated_at"`
|
|
Generation int64 `json:"generation"`
|
|
MemberCount int `json:"member_count"`
|
|
HostConnected bool `json:"host_connected"`
|
|
SelfRole MemberRole `json:"self_role"`
|
|
SelfCanControlTransport bool `json:"self_can_control_transport"`
|
|
SelfCanManageRoom bool `json:"self_can_manage_room"`
|
|
SelfIgnoreWait bool `json:"self_ignore_wait"`
|
|
AttachedSessionID string `json:"attached_session_id,omitempty"`
|
|
InvitePath string `json:"invite_path,omitempty"`
|
|
Members []MemberSummary `json:"members,omitempty"`
|
|
}
|
|
|
|
type RoomJoinResult struct {
|
|
Snapshot Snapshot
|
|
AccessToken string
|
|
}
|
|
|
|
type CreateRoomInput struct {
|
|
HostUserID int
|
|
HostProfileID string
|
|
SelectionMode RoomSelectionMode
|
|
}
|
|
|
|
type JoinInput struct {
|
|
Code string
|
|
JoinToken string
|
|
}
|
|
|
|
type SelectItemInput struct {
|
|
ContentID string
|
|
FileID *int
|
|
LibraryID *int
|
|
}
|
|
|
|
type ResolvedSelection struct {
|
|
ContentID string
|
|
FileID *int
|
|
LibraryID *int
|
|
}
|
|
|
|
type TransportRequest struct {
|
|
Action TransportAction
|
|
PositionSeconds *float64
|
|
IsPaused bool
|
|
}
|
|
|
|
type StateReport struct {
|
|
SessionID string
|
|
PositionSeconds float64
|
|
IsPaused bool
|
|
}
|
|
|
|
type TransportCommand struct {
|
|
CommandID string `json:"command_id"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
SelectionRevision int64 `json:"selection_revision"`
|
|
Action TransportAction `json:"action"`
|
|
PositionSeconds float64 `json:"position_seconds"`
|
|
ExecuteAt string `json:"execute_at"`
|
|
IssuedAt string `json:"issued_at"`
|
|
PlaybackState RoomPlaybackState `json:"playback_state"`
|
|
}
|
|
|
|
// Suggestion represents a content suggestion in a vote-mode room.
|
|
type Suggestion struct {
|
|
ID string `json:"id"`
|
|
RoomID string `json:"room_id"`
|
|
SuggesterUserID int `json:"suggester_user_id"`
|
|
SuggesterProfileID string `json:"suggester_profile_id"`
|
|
ContentID string `json:"content_id"`
|
|
ContentType string `json:"content_type"`
|
|
Title string `json:"title"`
|
|
Subtitle string `json:"subtitle"`
|
|
PosterURL string `json:"poster_url"`
|
|
Note string `json:"note"`
|
|
VoteCount int `json:"vote_count"`
|
|
VotedByMe bool `json:"voted_by_me"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type CreateSuggestionInput struct {
|
|
ContentID string
|
|
ContentType string
|
|
Title string
|
|
Subtitle string
|
|
PosterURL string
|
|
Note string
|
|
}
|
|
|
|
// SuggestionStore provides persistence for room suggestions and votes.
|
|
type SuggestionStore interface {
|
|
CreateSuggestion(ctx context.Context, s Suggestion) (*Suggestion, error)
|
|
GetSuggestion(ctx context.Context, id string) (*Suggestion, error)
|
|
ListSuggestions(ctx context.Context, roomID string, voterProfileID string) ([]Suggestion, error)
|
|
DeleteSuggestion(ctx context.Context, id string) error
|
|
AddVote(ctx context.Context, suggestionID string, voterProfileID string) error
|
|
RemoveVote(ctx context.Context, suggestionID string, voterProfileID string) error
|
|
}
|