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>
137 lines
4.3 KiB
TypeScript
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;
|
|
}
|