watchparty/client/src/global.d.ts
Nicolas a7944538bc
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 7s
Replace native <video controls> with custom transport controls
Reported right after trying the player rework: the action bar looked
like two z-index layers with some elements duplicated, only in
fullscreen. Root cause: video.controls = true (set for the host since
this player's first version) has always rendered the browser's own
native control bar - a separate piece of UI in its own always-on-top
compositing layer, independent of this app's DOM/CSS. That was never
visible before, since fullscreen used to show only the video; once it
correctly started showing this app's own overlays too, the native bar
and the custom one both became visible, and specifically duplicated -
Chrome adds its own cast icon to a <video>'s native controls once it
detects a Cast-capable device, entirely separate from #castBtn's own
integration.

Two fixes:
- disableremoteplayback on <video> - the standard attribute telling
  the browser not to add its own cast icon, since this app has its own.
  Should have shipped with the original Chromecast feature.
- Native <video controls> retired entirely, everywhere (not just
  fullscreen), replaced with a custom transport bar - asked for
  directly rather than just suppressing the symptom in fullscreen.

PlaybackDriver (cast.ts) gained `duration` alongside currentTime/paused -
the one thing a seek bar needs that the existing local/cast abstraction
didn't yet expose. #bottomBar gained a seek row (progressBar/
progressFill) above the existing button row - host drags/clicks to seek
by fraction of activeDriver.duration (pointer events, mouse and touch
share one path); a follower's bar is the same element, read-only. A
500ms ticker keeps the fill/time label moving between discrete events.
#playPauseBtn (host-only) and #muteBtn (everyone, local-only - never
synced, hidden while casting since the local <video> is silent then)
round out the replacement. One function, onDriverStateChanged, now
threads through every discrete play/pause/seek event (the local
<video>'s native events, and a cast session's RemotePlayerController
events) so the button icon, the seek bar, and the party broadcast stay
in lockstep regardless of which driver or gesture caused the change.

Verified: full typecheck/build. Checked in a real browser against the
compiled bundle - injected the new markup for both a playing/unmuted
and a paused/muted state, confirmed icons/fill/time label/readOnly
cursor all render as designed. Not verified: a real drag-to-seek
gesture or real playback - no reachable Emby stream in this sandbox to
attach a real <video> source to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 11:30:50 +02:00

137 lines
4.3 KiB
TypeScript

/**
* Ambient declarations for things esbuild/the browser provide that aren't
* npm packages - see client/build.mjs's `.html`-as-text loader, and
* client/index.html's CDN <script> tags for hls.js/socket.io (both used
* as plain globals, not imported - keeping the "pin an exact CDN version,
* no bundler-managed copy" choice this app made from the start).
*/
/** Each component's own .html template, imported as a raw string - see
* any view's own .ts file for the pattern (`import html from './Foo.html'`). */
declare module '*.html' {
const content: string;
export default content;
}
/** Imported purely for esbuild's CSS bundling side effect (see any view's
* own `import './Foo.css'`) - there's nothing to type, the import itself
* is the whole point. */
declare module '*.css';
// --- socket.io-client (CDN global, see client/index.html) -------------------
// Minimal shape covering only what this app actually calls - not the full
// socket.io-client API surface.
interface AppClientSocket {
on(event: string, handler: (...args: unknown[]) => void): void;
emit(event: string, payload?: unknown): void;
disconnect(): void;
}
declare function io(): AppClientSocket;
// --- hls.js (CDN global, see client/index.html) -----------------------------
// Same idea - only the surface client/src/views/player and musicBar
// actually use.
interface HlsErrorData {
fatal: boolean;
[key: string]: unknown;
}
interface HlsInstance {
on(event: string, handler: (event: string, data: HlsErrorData) => void): void;
loadSource(url: string): void;
attachMedia(media: HTMLMediaElement): void;
destroy(): void;
}
/** Only the loading-timeout knobs Player.ts/musicBar actually override -
* see their own HLS_CONFIG comment for why the defaults are too tight
* for this app's real transcode latency. */
interface HlsConfig {
manifestLoadingTimeOut?: number;
manifestLoadingMaxRetry?: number;
levelLoadingTimeOut?: number;
fragLoadingTimeOut?: number;
}
interface HlsStatic {
new (config?: HlsConfig): HlsInstance;
isSupported(): boolean;
Events: { MANIFEST_PARSED: string; ERROR: string };
}
declare const Hls: HlsStatic | undefined;
interface Window {
Hls?: HlsStatic;
}
// --- Google Cast Sender SDK (CDN global, see client/src/index.html) --------
// Minimal shape covering only what client/src/cast.ts actually calls - not
// the full Cast Application Framework API surface. Accessed only via
// `window.chrome`/`window.cast` there (never the bare identifiers), same
// reasoning as `window.Hls` above: neither is guaranteed to exist at all
// outside a Chromium-family browser, and a bare undeclared identifier read
// (unlike a property read off `window`) throws instead of yielding undefined.
interface CastMediaInfo {
metadata?: { title?: string };
}
interface CastSession {
loadMedia(request: unknown): Promise<void>;
endSession(stopCasting: boolean): void;
}
interface CastContextEvent {
castState?: string;
sessionState?: string;
}
interface CastContext {
setOptions(options: { receiverApplicationId: string; autoJoinPolicy: string }): void;
requestSession(): Promise<void>;
getCurrentSession(): CastSession | null;
getCastState(): string;
addEventListener(type: string, handler: (event: CastContextEvent) => void): void;
}
interface RemotePlayer {
isPaused: boolean;
currentTime: number;
duration: number;
}
interface RemotePlayerController {
playOrPause(): void;
seek(): void;
addEventListener(type: string, handler: () => void): void;
}
interface CastFramework {
CastContext: { getInstance(): CastContext };
CastContextEventType: { CAST_STATE_CHANGED: string; SESSION_STATE_CHANGED: string };
CastState: { NO_DEVICES_AVAILABLE: string };
SessionState: { SESSION_ENDED: string };
RemotePlayer: new () => RemotePlayer;
RemotePlayerController: new (player: RemotePlayer) => RemotePlayerController;
RemotePlayerEventType: { IS_PAUSED_CHANGED: string; CURRENT_TIME_CHANGED: string };
}
interface Window {
chrome?: {
cast?: {
isAvailable?: boolean;
AutoJoinPolicy: { ORIGIN_SCOPED: string };
media: {
DEFAULT_MEDIA_RECEIVER_APP_ID: string;
MediaInfo: new (contentId: string, contentType: string) => CastMediaInfo;
LoadRequest: new (mediaInfo: CastMediaInfo) => unknown;
};
};
};
cast?: { framework: CastFramework };
__onGCastApiAvailable?: (isAvailable: boolean) => void;
}