105 lines
3.4 KiB
TypeScript
105 lines
3.4 KiB
TypeScript
import React, { MouseEventHandler, ReactNode, useRef } from 'react';
|
|
import { toRem } from 'folds';
|
|
import {
|
|
StreamMediaBubble,
|
|
StreamMediaUsernameLabel,
|
|
StreamMediaUsernameOverlay,
|
|
} from './StreamMedia.css';
|
|
import { logMedia, useMediaMeasureDebug } from './streamMediaDebug';
|
|
|
|
const STREAM_MEDIA_MAX_DIM = 320;
|
|
const STREAM_MEDIA_MIN_ASPECT = 3 / 4;
|
|
const STREAM_MEDIA_MAX_ASPECT = 4 / 3;
|
|
|
|
export type StreamMediaShellProps = {
|
|
naturalW?: number;
|
|
naturalH?: number;
|
|
own: boolean;
|
|
overlay?: ReactNode;
|
|
senderId?: string;
|
|
onUsernameClick?: MouseEventHandler<HTMLButtonElement>;
|
|
onUsernameContextMenu?: MouseEventHandler<HTMLButtonElement>;
|
|
children: ReactNode;
|
|
};
|
|
|
|
function computeBoxStyle(naturalW?: number, naturalH?: number): React.CSSProperties {
|
|
// Explicit pixel width + height — required so the inner content (folds
|
|
// ImageContent / VideoContent — `RelativeBase` with `height: 100%`) gets
|
|
// a definite parent height to resolve against. `max-width: 100%` from
|
|
// StreamMediaBubble + the surrounding StreamBubble's mediaMode
|
|
// `display: block; width: 100%` chain shrinks the bubble on narrow
|
|
// viewports; the small aspect drift this introduces is masked by
|
|
// `object-fit: cover` (image) / `contain` (video) on the inner element.
|
|
const naturalAspect = naturalW && naturalH ? naturalW / naturalH : NaN;
|
|
if (
|
|
!Number.isFinite(naturalAspect) ||
|
|
naturalAspect < STREAM_MEDIA_MIN_ASPECT ||
|
|
naturalAspect > STREAM_MEDIA_MAX_ASPECT
|
|
) {
|
|
return { width: toRem(STREAM_MEDIA_MAX_DIM), height: toRem(STREAM_MEDIA_MAX_DIM) };
|
|
}
|
|
if (naturalAspect >= 1) {
|
|
const w = Math.min(STREAM_MEDIA_MAX_DIM, naturalW!);
|
|
return { width: toRem(w), height: toRem(w / naturalAspect) };
|
|
}
|
|
const h = Math.min(STREAM_MEDIA_MAX_DIM, naturalH!);
|
|
return { width: toRem(h * naturalAspect), height: toRem(h) };
|
|
}
|
|
|
|
// Shared chrome for image / video timeline bubbles: square-ish bubble with
|
|
// asymmetric notch + 1px pseudo-frame, and the sender username overlaid
|
|
// top-left as a text chip. Caller plugs in the actual media renderer as
|
|
// `children`.
|
|
//
|
|
// Tab order: chip rendered BEFORE children so keyboard focus visits the
|
|
// username (top-left visual) before the media tap target — matches reading
|
|
// order.
|
|
export function StreamMediaShell({
|
|
naturalW,
|
|
naturalH,
|
|
own,
|
|
overlay,
|
|
senderId,
|
|
onUsernameClick,
|
|
onUsernameContextMenu,
|
|
children,
|
|
}: StreamMediaShellProps) {
|
|
const bubbleRef = useRef<HTMLDivElement>(null);
|
|
const computedStyle = computeBoxStyle(naturalW, naturalH);
|
|
|
|
logMedia('StreamMediaShell', {
|
|
own,
|
|
naturalW,
|
|
naturalH,
|
|
overlayPresent: !!overlay,
|
|
senderId,
|
|
computedStyle: { ...computedStyle },
|
|
});
|
|
|
|
useMediaMeasureDebug(bubbleRef, [computedStyle.width, computedStyle.height]);
|
|
|
|
return (
|
|
<div ref={bubbleRef} className={StreamMediaBubble({ own })} style={computedStyle}>
|
|
{overlay && (
|
|
<div className={StreamMediaUsernameOverlay}>
|
|
<button
|
|
type="button"
|
|
className={StreamMediaUsernameLabel}
|
|
data-user-id={senderId}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onUsernameClick?.(e);
|
|
}}
|
|
onContextMenu={(e) => {
|
|
e.stopPropagation();
|
|
onUsernameContextMenu?.(e);
|
|
}}
|
|
>
|
|
{overlay}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|