Files
silo-server/internal/watchtogether/types.go
T
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

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
}