/* Copyright 2024 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ import { type ComponentProps, type FC, type Ref, type RefAttributes, useCallback, useEffect, useRef, useState, } from "react"; import { ExpandIcon, CollapseIcon, ChevronLeftIcon, ChevronRightIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; import { animated } from "@react-spring/web"; import { type Observable, map } from "rxjs"; import { useObservableRef } from "observable-hooks"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; import { type TrackReferenceOrPlaceholder } from "@livekit/components-core"; import { type RoomMember } from "matrix-js-sdk"; import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react"; import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react"; import { MediaView } from "./MediaView"; import styles from "./SpotlightTile.module.css"; import { type EncryptionStatus, LocalUserMediaViewModel, type MediaViewModel, ScreenShareViewModel, type UserMediaViewModel, } from "../state/MediaViewModel"; import { useInitial } from "../useInitial"; import { useMergedRefs } from "../useMergedRefs"; import { useReactiveState } from "../useReactiveState"; import { useLatest } from "../useLatest"; import { type SpotlightTileViewModel } from "../state/TileViewModel"; import { useBehavior } from "../useBehavior"; interface SpotlightItemBaseProps { ref?: Ref; className?: string; "data-id": string; targetWidth: number; targetHeight: number; video: TrackReferenceOrPlaceholder | undefined; member: RoomMember | undefined; unencryptedWarning: boolean; encryptionStatus: EncryptionStatus; displayName: string; "aria-hidden"?: boolean; localParticipant: boolean; } interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps { videoEnabled: boolean; videoFit: "contain" | "cover"; } interface SpotlightLocalUserMediaItemProps extends SpotlightUserMediaItemBaseProps { vm: LocalUserMediaViewModel; } const SpotlightLocalUserMediaItem: FC = ({ vm, ...props }) => { const mirror = useBehavior(vm.mirror$); return ; }; SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem"; interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps { vm: UserMediaViewModel; } const SpotlightUserMediaItem: FC = ({ vm, ...props }) => { const videoEnabled = useBehavior(vm.videoEnabled$); const cropVideo = useBehavior(vm.cropVideo$); const baseProps: SpotlightUserMediaItemBaseProps & RefAttributes = { videoEnabled, videoFit: cropVideo ? "cover" : "contain", ...props, }; return vm instanceof LocalUserMediaViewModel ? ( ) : ( ); }; SpotlightUserMediaItem.displayName = "SpotlightUserMediaItem"; interface SpotlightItemProps { ref?: Ref; vm: MediaViewModel; targetWidth: number; targetHeight: number; intersectionObserver$: Observable; /** * Whether this item should act as a scroll snapping point. */ snap: boolean; "aria-hidden"?: boolean; } const SpotlightItem: FC = ({ ref: theirRef, vm, targetWidth, targetHeight, intersectionObserver$, snap, "aria-hidden": ariaHidden, }) => { const ourRef = useRef(null); const ref = useMergedRefs(ourRef, theirRef); const displayName = useBehavior(vm.displayName$); const video = useBehavior(vm.video$); const unencryptedWarning = useBehavior(vm.unencryptedWarning$); const encryptionStatus = useBehavior(vm.encryptionStatus$); // Hook this item up to the intersection observer useEffect(() => { const element = ourRef.current!; let prevIo: IntersectionObserver | null = null; const subscription = intersectionObserver$.subscribe((io) => { prevIo?.unobserve(element); io.observe(element); prevIo = io; }); return (): void => { subscription.unsubscribe(); prevIo?.unobserve(element); }; }, [intersectionObserver$]); const baseProps: SpotlightItemBaseProps & RefAttributes = { ref, "data-id": vm.id, className: classNames(styles.item, { [styles.snap]: snap }), targetWidth, targetHeight, video, member: vm.member, unencryptedWarning, displayName, encryptionStatus, "aria-hidden": ariaHidden, localParticipant: vm.local, }; return vm instanceof ScreenShareViewModel ? ( ) : ( ); }; SpotlightItem.displayName = "SpotlightItem"; interface Props { ref?: Ref; vm: SpotlightTileViewModel; expanded: boolean; onToggleExpanded: (() => void) | null; targetWidth: number; targetHeight: number; showIndicators: boolean; className?: string; style?: ComponentProps["style"]; } export const SpotlightTile: FC = ({ ref: theirRef, vm, expanded, onToggleExpanded, targetWidth, targetHeight, showIndicators, className, style, }) => { const { t } = useTranslation(); const [ourRef, root$] = useObservableRef(null); const ref = useMergedRefs(ourRef, theirRef); const maximised = useBehavior(vm.maximised$); const media = useBehavior(vm.media$); const [visibleId, setVisibleId] = useState(media[0]?.id); const latestMedia = useLatest(media); const latestVisibleId = useLatest(visibleId); const visibleIndex = media.findIndex((vm) => vm.id === visibleId); const canGoBack = visibleIndex > 0; const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1; const isFullscreen = useCallback((): boolean => { const rootElement = document.body; if (rootElement && document.fullscreenElement) return true; return false; }, []); const FullScreenIcon = isFullscreen() ? FullScreenMinimiseIcon : FullScreenMaximiseIcon; const onToggleFullscreen = useCallback(() => { const rootElement = document.body; if (!rootElement) return; if (isFullscreen()) { void document?.exitFullscreen(); } else { void rootElement.requestFullscreen(); } }, [isFullscreen]); // To keep track of which item is visible, we need an intersection observer // hooked up to the root element and the items. Because the items will run // their effects before their parent does, we need to do this dance with an // Observable to actually give them the intersection observer. const intersectionObserver$ = useInitial>( () => root$.pipe( map( (r) => new IntersectionObserver( (entries) => { const visible = entries.find((e) => e.isIntersecting); if (visible !== undefined) setVisibleId(visible.target.getAttribute("data-id")!); }, { root: r, threshold: 0.5 }, ), ), ), ); const [scrollToId, setScrollToId] = useReactiveState( (prev) => prev == null || prev === visibleId || media.every((vm) => vm.id !== prev) ? null : prev, [visibleId], ); const onBackClick = useCallback(() => { const media = latestMedia.current; const visibleIndex = media.findIndex( (vm) => vm.id === latestVisibleId.current, ); if (visibleIndex > 0) setScrollToId(media[visibleIndex - 1].id); }, [latestVisibleId, latestMedia, setScrollToId]); const onNextClick = useCallback(() => { const media = latestMedia.current; const visibleIndex = media.findIndex( (vm) => vm.id === latestVisibleId.current, ); if (visibleIndex !== -1 && visibleIndex !== media.length - 1) setScrollToId(media[visibleIndex + 1].id); }, [latestVisibleId, latestMedia, setScrollToId]); const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon; return ( {canGoBack && ( )}
{media.map((vm) => ( ))}
{onToggleExpanded && ( )}
{canGoToNext && ( )} {!expanded && (
1, })} > {media.map((vm) => (
))}
)} ); }; SpotlightTile.displayName = "SpotlightTile";