Asked for directly. Checked the real server before building anything: no plugin provides this (Cinema Intros is a different feature - pre-show trailers, not per-episode skip points), and Emby's own Chapters field turned out to be auto-generated placeholders every 5 minutes, not real markers. What the server does have natively, confirmed from its own /openapi.json rather than assumed: a per-library EnableMarkerDetection option backed by a built-in "Detect Episode Intros" scheduled task - audio-fingerprint detection, the same idea as Jellyfin's separate Intro Skipper plugin, already part of Emby itself. Enabling it (done directly on the server for the two TV libraries) adds real entries to the same Chapters array, distinguished by MarkerType: IntroStart/IntroEnd/ CreditsStart. This made the feature almost entirely "read what Emby already computed" - no fingerprinting, no new persistence, no background jobs of this app's own. CatalogService.getPlaybackMarkers fetches Fields=Chapters and picks out just those three marker types (nulls when detection hasn't reached this item or isn't enabled for its library - never an error), behind a new GET /api/catalog/item/:id/markers route mirroring getPlaybackOptions. The player gets one new floating button, #skipIntroBtn - an explicit label, not an icon, matching this app's own convention for every request-shaped control. It's a sibling of #bottomBar, not inside it, so it stays reachable through the bar's own auto-hide/.minimal collapse. Clicking it seeks to introEndTicks through the exact same activeDriver/onDriverStateChanged path any other seek uses, so it's automatically host-only and automatically cast-aware with no new special-casing for either. End credits got no button of their own - #nextBtn already does exactly "go to the next queued item" and was already shown whenever hasNextQueued is true; the only gap closed here is visibility, by un-minimizing the bar once (a one-shot flag, not a fight against someone deliberately re-minimizing it) the moment playback crosses creditsStartTicks. Verified: full typecheck/test/build on both projects (125/125 server tests, including new coverage for getPlaybackMarkers - real MarkerType values mixed with auto-generated Chapter ones, and both "no Chapters" and "no markers among the Chapters" resolving to nulls rather than throwing). The button's on-screen position was checked directly (getBoundingClientRect against the real compiled CSS) at two viewport sizes - clear of #bottomBar with a 10px gap, not overlapping. Not verified: the detection task had only just been enabled on the real server and hadn't finished analyzing any episode yet, so the actual end-to-end "a detected marker makes the button appear at the right second" behavior needs a real check once it's had time to run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
217 lines
6.7 KiB
TypeScript
217 lines
6.7 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;
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|