A watch party built from a whole series or video playlist that remembers the group's progress and each member's, so someone coming back or joining late sees the items they missed and can catch up (solo, as a catch-up watch party, or by marking items watched). - StaticPartyService + StaticPartyStore: first on-disk state (JSON file under DATA_DIR, atomic debounced writes, flushed on SIGTERM) - each session is an ordinary Party linked via party.staticLink; group position is saved by the 20s sweep and resumed on the next session - PartyService.completeCurrentItem centralizes "item done" (Emby Played + static progress); new host-only "itemEnded" socket event covers the last queued item, which was never marked Played before - missed = group-completed minus watched in-app minus Emby Played - /static/:id view, create buttons on series/playlist pages, home section - Docker volume for /data, spec/static-watch-party.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
257 lines
8 KiB
TypeScript
257 lines
8 KiB
TypeScript
/**
|
|
* Shared client-side types - overlapping copies of server/src/types.ts's
|
|
* DTO shapes rather than a real shared package (the two projects compile
|
|
* independently - tsc for the server, esbuild for the client - and a
|
|
* shared package would be a bigger structural commitment than this app's
|
|
* size justifies). See docs/architecture.md.
|
|
*/
|
|
|
|
export interface ItemDto {
|
|
id: string;
|
|
name: string;
|
|
/** Emby's own sort key (articles stripped) when the server requested
|
|
* it, the display name otherwise - see server/src/types.ts's own
|
|
* ItemDto.sortName for why alphaIndex.ts buckets by this, not name. */
|
|
sortName: string;
|
|
overview?: string;
|
|
year?: number;
|
|
type?: string;
|
|
canWatchParty: boolean;
|
|
imageUrl: string;
|
|
embyUrl: string;
|
|
durationTicks: number | null;
|
|
indexNumber: number | null;
|
|
artistName: string | null;
|
|
albumName: string | null;
|
|
}
|
|
|
|
export interface LibraryDto {
|
|
id: string;
|
|
name: string;
|
|
collectionType: string;
|
|
}
|
|
|
|
export interface SeasonDto {
|
|
id: string;
|
|
name: string;
|
|
indexNumber?: number;
|
|
}
|
|
|
|
export interface PlaybackTrackOption {
|
|
index: number;
|
|
label: string;
|
|
// Subtitle tracks only (undefined for audio) - false for an image/
|
|
// bitmap subtitle (PGS/VobSub/DVB - a Blu-ray/DVD rip's own subtitles,
|
|
// typically). The player renders subtitles as a native <track> (WebVTT,
|
|
// converted server-side - see api/embyApi.ts's subtitleUrl and
|
|
// Player.ts's applySubtitleTrack), which needs actual text; a bitmap
|
|
// has none to extract.
|
|
isTextBased?: boolean;
|
|
}
|
|
|
|
export interface PlaybackOptionsDto {
|
|
audioTracks: PlaybackTrackOption[];
|
|
subtitleTracks: PlaybackTrackOption[];
|
|
}
|
|
|
|
/** Emby's own native intro/credits detection - see server/src/types.ts's
|
|
* own copy of this shape for where the field names come from. Every
|
|
* field is independently nullable ("not detected", never an error) -
|
|
* see views/player/Player.ts's updateSkipButton, the only consumer. */
|
|
export interface PlaybackMarkersDto {
|
|
introStartTicks: number | null;
|
|
introEndTicks: number | null;
|
|
creditsStartTicks: number | null;
|
|
}
|
|
|
|
export type MediaType = 'video' | 'music';
|
|
|
|
export interface PublicMember {
|
|
userId: string;
|
|
userName: string;
|
|
isHost: boolean;
|
|
isPaused: boolean;
|
|
positionTicks: number;
|
|
connected: boolean;
|
|
}
|
|
|
|
export interface MusicTrack {
|
|
itemId: string;
|
|
itemName: string;
|
|
artistName: string | null;
|
|
albumName: string | null;
|
|
itemImageUrl: string;
|
|
durationSeconds: number | null;
|
|
addedByUserId: string;
|
|
addedByUserName: string;
|
|
}
|
|
|
|
/** What this app's own catalog DTO (ItemDto) looks like once reshaped into
|
|
* a track add request - see Album.ts's addTrackToQueue calls. */
|
|
export interface MusicTrackInput {
|
|
itemId: string;
|
|
itemName: string;
|
|
artistName?: string | null;
|
|
albumName?: string | null;
|
|
itemImageUrl: string;
|
|
durationTicks?: number | null;
|
|
}
|
|
|
|
export interface MusicCurrentPublic extends MusicTrack {
|
|
isPaused: boolean;
|
|
elapsedSeconds: number;
|
|
}
|
|
|
|
export interface MusicPublicState {
|
|
queue: MusicTrack[];
|
|
current: MusicCurrentPublic | null;
|
|
skipVotes: number;
|
|
}
|
|
|
|
export interface PartyPublicState {
|
|
partyId: string;
|
|
mediaType: MediaType;
|
|
itemId: string;
|
|
itemName: string;
|
|
itemImageUrl: string;
|
|
createdUtc: number;
|
|
hasNext: boolean;
|
|
queueLength: number;
|
|
queueIndex: number;
|
|
hostUserName: string | null;
|
|
memberCount: number;
|
|
youAreHost: boolean;
|
|
members: PublicMember[];
|
|
music: MusicPublicState | null;
|
|
// Video only - the host's current pick for the party's shared burn-in
|
|
// stream (see server/src/services/TranscodeService.ts), null when the
|
|
// party is just using its normal party-shared Emby stream instead
|
|
// (PartyStreamService - also shared, just proxied rather than
|
|
// transcoded by us). Both null or both set together - see
|
|
// SocketGateway's own comment.
|
|
burnInSubtitleIndex: string | null;
|
|
burnInAudioIndex: string | null;
|
|
// Video only - the host's current pick for a *text* subtitle, party-
|
|
// wide now (not each viewer's own local pick any more - that turned
|
|
// out to be the wrong call once people actually watched together, see
|
|
// docs/architecture.md). Mutually exclusive with burnInSubtitleIndex -
|
|
// see SocketGateway's "setTextSubtitle" handler.
|
|
textSubtitleIndex: string | null;
|
|
// Video only - the host's current pick for the party's normal shared
|
|
// stream (PartyStreamService) - null means "Emby's own default".
|
|
// There's only ever one stream now, so this is party-wide, host-set
|
|
// state, not a per-viewer local choice the way it was before streams
|
|
// were shared - see SocketGateway's "setStreamOptions" handler.
|
|
streamAudioIndex: string | null;
|
|
streamQuality: string | null;
|
|
}
|
|
|
|
/** The socket "members" broadcast's shape - smaller than
|
|
* PartyPublicState.members above (no isPaused/positionTicks) - see
|
|
* server/src/types.ts's SocketMemberDto for why. */
|
|
export interface SocketMember {
|
|
userId: string;
|
|
userName: string;
|
|
isHost: boolean;
|
|
connected: boolean;
|
|
}
|
|
|
|
export interface LobbyPartyDto {
|
|
partyId: string;
|
|
mediaType: MediaType;
|
|
itemId: string;
|
|
itemName: string;
|
|
itemImageUrl: string;
|
|
hostUserName: string | null;
|
|
memberCount: number;
|
|
createdUtc: number;
|
|
// Video only - null for music. See Home.ts's updateElapsedLabels() for
|
|
// how these turn into a live, pause-aware "elapsed" label instead of
|
|
// just ticking wall-clock time since createdUtc regardless of pauses.
|
|
positionTicks: number | null;
|
|
isPaused: boolean | null;
|
|
positionUpdatedUtc: number | null;
|
|
/** The static watch party this live session belongs to, if any. */
|
|
staticPartyId: string | null;
|
|
staticPartyName: string | null;
|
|
}
|
|
|
|
/** Mirrors server/src/types.ts's StaticPartySummaryDto - one static
|
|
* (persistent) watch party, as the home page lists it. */
|
|
export interface StaticPartySummaryDto {
|
|
id: string;
|
|
name: string;
|
|
sourceType: 'series' | 'playlist';
|
|
imageUrl: string;
|
|
itemCount: number;
|
|
groupWatchedCount: number;
|
|
nextItemName: string | null;
|
|
memberCount: number;
|
|
youAreMember: boolean;
|
|
youAreCreator: boolean;
|
|
missedCount: number;
|
|
livePartyId: string | null;
|
|
}
|
|
|
|
/** One item of a static watch party - mirrors StaticPartyItemDto. */
|
|
export interface StaticPartyItemDto {
|
|
itemId: string;
|
|
itemName: string;
|
|
itemImageUrl: string;
|
|
groupState: 'watched' | 'next' | 'upcoming';
|
|
viewerWatched: boolean;
|
|
}
|
|
|
|
/** Mirrors server/src/types.ts's StaticPartyDetailDto. */
|
|
export interface StaticPartyDetailDto extends StaticPartySummaryDto {
|
|
items: StaticPartyItemDto[];
|
|
missedItemIds: string[];
|
|
members: Array<{ userId: string; userName: string; watchedCount: number; isCreator: boolean }>;
|
|
}
|
|
|
|
export interface ItemChangedMsg {
|
|
itemId: string;
|
|
itemName: string;
|
|
itemImageUrl: string;
|
|
queueIndex: number;
|
|
queueLength: number;
|
|
hasNext: boolean;
|
|
}
|
|
|
|
export interface PauseRequestedMsg {
|
|
userId: string;
|
|
userName: string;
|
|
}
|
|
|
|
/** Same shape as PauseRequestedMsg, kept as its own named type anyway
|
|
* (not reused) so a callback's own type signature says which request it's
|
|
* actually for - see api/party.ts's PartyConnectionCallbacks. */
|
|
export interface ResumeRequestedMsg {
|
|
userId: string;
|
|
userName: string;
|
|
}
|
|
|
|
/** "burnInChanged" - see api/party.ts's setBurnInSubtitle/onBurnInChanged
|
|
* and PartyPublicState.burnInSubtitleIndex above (this is the live-push
|
|
* half; that's the on-load/rejoin half). Both null together clears the
|
|
* party's burn-in stream back to its normal party-shared one instead. */
|
|
export interface BurnInChangedMsg {
|
|
subtitleIndex: string | null;
|
|
audioIndex: string | null;
|
|
}
|
|
|
|
/** "textSubtitleChanged" - see api/party.ts's setTextSubtitle/
|
|
* onTextSubtitleChanged and PartyPublicState.textSubtitleIndex above
|
|
* (this is the live-push half; that's the on-load/rejoin half). */
|
|
export interface TextSubtitleChangedMsg {
|
|
subtitleIndex: string | null;
|
|
}
|
|
|
|
/** "streamOptionsChanged" - see api/party.ts's setStreamOptions/
|
|
* onStreamOptionsChanged and PartyPublicState.streamAudioIndex/
|
|
* streamQuality above (this is the live-push half; that's the
|
|
* on-load/rejoin half). */
|
|
export interface StreamOptionsChangedMsg {
|
|
audioStreamIndex: string | null;
|
|
quality: string | null;
|
|
}
|