/* Copyright 2024 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import { ComponentProps, RefAttributes, forwardRef, useCallback, useEffect, useRef, useState, } from "react"; import { Glass } from "@vector-im/compound-web"; import ExpandIcon from "@vector-im/compound-design-tokens/icons/expand.svg?react"; import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?react"; import ChevronLeftIcon from "@vector-im/compound-design-tokens/icons/chevron-left.svg?react"; import ChevronRightIcon from "@vector-im/compound-design-tokens/icons/chevron-right.svg?react"; import { animated } from "@react-spring/web"; import { Observable, map } from "rxjs"; import { useObservableEagerState } from "observable-hooks"; import { useTranslation } from "react-i18next"; import classNames from "classnames"; import { TrackReferenceOrPlaceholder } from "@livekit/components-core"; import { RoomMember } from "matrix-js-sdk"; import { MediaView } from "./MediaView"; import styles from "./SpotlightTile.module.css"; import { LocalUserMediaViewModel, MediaViewModel, ScreenShareViewModel, UserMediaViewModel, useNameData, } from "../state/MediaViewModel"; import { useInitial } from "../useInitial"; import { useMergedRefs } from "../useMergedRefs"; import { useObservableRef } from "../state/useObservable"; import { useReactiveState } from "../useReactiveState"; import { useLatest } from "../useLatest"; interface SpotlightItemBaseProps { className?: string; "data-id": string; targetWidth: number; targetHeight: number; video: TrackReferenceOrPlaceholder; member: RoomMember | undefined; unencryptedWarning: boolean; nameTag: string; displayName: string; } interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps { videoEnabled: boolean; videoFit: "contain" | "cover"; } interface SpotlightLocalUserMediaItemProps extends SpotlightUserMediaItemBaseProps { vm: LocalUserMediaViewModel; } const SpotlightLocalUserMediaItem = forwardRef< HTMLDivElement, SpotlightLocalUserMediaItemProps >(({ vm, ...props }, ref) => { const mirror = useObservableEagerState(vm.mirror); return ; }); SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem"; interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps { vm: UserMediaViewModel; } const SpotlightUserMediaItem = forwardRef< HTMLDivElement, SpotlightUserMediaItemProps >(({ vm, ...props }, ref) => { const videoEnabled = useObservableEagerState(vm.videoEnabled); const cropVideo = useObservableEagerState(vm.cropVideo); const baseProps: SpotlightUserMediaItemBaseProps = { videoEnabled, videoFit: cropVideo ? "cover" : "contain", ...props, }; return vm instanceof LocalUserMediaViewModel ? ( ) : ( ); }); SpotlightUserMediaItem.displayName = "SpotlightUserMediaItem"; interface SpotlightItemProps { vm: MediaViewModel; targetWidth: number; targetHeight: number; intersectionObserver: Observable; /** * Whether this item should act as a scroll snapping point. */ snap: boolean; } const SpotlightItem = forwardRef( ({ vm, targetWidth, targetHeight, intersectionObserver, snap }, theirRef) => { const ourRef = useRef(null); const ref = useMergedRefs(ourRef, theirRef); const { displayName, nameTag } = useNameData(vm); const video = useObservableEagerState(vm.video); const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning); // 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, nameTag, displayName, }; return vm instanceof ScreenShareViewModel ? ( ) : ( ); }, ); SpotlightItem.displayName = "SpotlightItem"; interface Props { vms: MediaViewModel[]; maximised: boolean; fullscreen: boolean; onToggleFullscreen: () => void; targetWidth: number; targetHeight: number; showIndicators: boolean; className?: string; style?: ComponentProps["style"]; } export const SpotlightTile = forwardRef( ( { vms, maximised, fullscreen, onToggleFullscreen, targetWidth, targetHeight, showIndicators, className, style, }, theirRef, ) => { const { t } = useTranslation(); const [root, ourRef] = useObservableRef(null); const ref = useMergedRefs(ourRef, theirRef); const [visibleId, setVisibleId] = useState(vms[0].id); const latestVms = useLatest(vms); const latestVisibleId = useLatest(visibleId); const visibleIndex = vms.findIndex((vm) => vm.id === visibleId); const canGoBack = visibleIndex > 0; const canGoToNext = visibleIndex !== -1 && visibleIndex < vms.length - 1; // 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 || vms.every((vm) => vm.id !== prev) ? null : prev, [visibleId], ); const onBackClick = useCallback(() => { const vms = latestVms.current; const visibleIndex = vms.findIndex( (vm) => vm.id === latestVisibleId.current, ); if (visibleIndex > 0) setScrollToId(vms[visibleIndex - 1].id); }, [latestVisibleId, latestVms, setScrollToId]); const onNextClick = useCallback(() => { const vms = latestVms.current; const visibleIndex = vms.findIndex( (vm) => vm.id === latestVisibleId.current, ); if (visibleIndex !== -1 && visibleIndex !== vms.length - 1) setScrollToId(vms[visibleIndex + 1].id); }, [latestVisibleId, latestVms, setScrollToId]); const FullScreenIcon = fullscreen ? CollapseIcon : ExpandIcon; // We need a wrapper element because Glass doesn't provide an animated.div return ( {canGoBack && ( )} {/* Similarly we need a wrapper element here because Glass expects a single child */}
{vms.map((vm) => ( ))}
{canGoToNext && ( )}
1, })} > {vms.map((vm) => (
))}
); }, ); SpotlightTile.displayName = "SpotlightTile";