diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index f9b5b3f11..ce8c84afb 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -22,21 +22,6 @@ jobs: - run: npm test working-directory: packages/react - lint-vue3: - name: Lint Vue3 codebase - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Use Node.js - uses: actions/setup-node@v4 - with: - node-version: '22.x' - - run: npm ci - working-directory: packages/vue3 - - run: npm run lint - working-directory: packages/vue3 - lint: name: Lint codebase runs-on: ubuntu-latest diff --git a/packages/react/package-lock.json b/packages/react/package-lock.json index 605e93a6d..d46ee4777 100644 --- a/packages/react/package-lock.json +++ b/packages/react/package-lock.json @@ -70,6 +70,7 @@ "react-day-picker": "^9.6.7", "react-dom": "^19.1.0", "react-error-boundary": "^4.0.13", + "react-grid-layout": "^1.5.2", "react-hook-form": "^7.56.3", "react-i18next": "^15.5.1", "react-image": "^4.1.0", @@ -7481,6 +7482,12 @@ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -10733,6 +10740,24 @@ "node": ">=8" } }, + "node_modules/react-grid-layout": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.2.tgz", + "integrity": "sha512-vT7xmQqszTT+sQw/LfisrEO4le1EPNnSEMVHy6sBZyzS3yGkMywdOd+5iEFFwQwt0NSaGkxuRmYwa1JsP6OJdw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-hook-form": { "version": "7.56.3", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.3.tgz", @@ -10892,6 +10917,19 @@ } } }, + "node_modules/react-resizable": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz", + "integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.0.3" + }, + "peerDependencies": { + "react": ">= 16.3" + } + }, "node_modules/react-resizable-panels": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.7.tgz", diff --git a/packages/react/package.json b/packages/react/package.json index e2ded9997..ae2e51c10 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -81,8 +81,10 @@ "react-day-picker": "^9.6.7", "react-dom": "^19.1.0", "react-error-boundary": "^4.0.13", - "react-hook-form": "^7.56.3", - "react-i18next": "^15.5.1", + "react-helmet-async": "^2.0.4", + "react-grid-layout": "^1.5.2", + "react-hook-form": "^7.49.2", + "react-i18next": "^15.2.0", "react-image": "^4.1.0", "react-linkify-it": "^1.0.8", "react-player": "^2.16.0", diff --git a/packages/react/src/components/examples/VideoAtomCleanupExample.tsx b/packages/react/src/components/examples/VideoAtomCleanupExample.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/react/src/components/header/userMenu/components/UserMenu.tsx b/packages/react/src/components/header/userMenu/components/UserMenu.tsx index a15c14b25..a3c8ebea3 100644 --- a/packages/react/src/components/header/userMenu/components/UserMenu.tsx +++ b/packages/react/src/components/header/userMenu/components/UserMenu.tsx @@ -56,7 +56,7 @@ export function UserMenu() { User avatar @@ -64,7 +64,7 @@ export function UserMenu() { CN diff --git a/packages/react/src/components/layout/PlayerWrapper.tsx b/packages/react/src/components/layout/PlayerWrapper.tsx index 280343ac5..40dfbb471 100644 --- a/packages/react/src/components/layout/PlayerWrapper.tsx +++ b/packages/react/src/components/layout/PlayerWrapper.tsx @@ -18,10 +18,11 @@ interface IPlayerWrapper { id: string; url: string; customSetPlayerRef?: React.Ref; + autoplay?: boolean; } export const PlayerWrapper = React.memo( - ({ id, url, customSetPlayerRef }: IPlayerWrapper) => { + ({ id, url, customSetPlayerRef, autoplay = true }: IPlayerWrapper) => { const playerRefAtom = videoPlayerRefAtomFamily(id); const setPlayerRef = useSetAtom(playerRefAtom); @@ -68,7 +69,7 @@ export const PlayerWrapper = React.memo( youtube: { playerVars: { origin: window.origin, - autoplay: 1, + autoplay: autoplay, }, }, }} diff --git a/packages/react/src/components/multiview/LiveChannel.tsx b/packages/react/src/components/multiview/LiveChannel.tsx deleted file mode 100644 index bc0468fee..000000000 --- a/packages/react/src/components/multiview/LiveChannel.tsx +++ /dev/null @@ -1,42 +0,0 @@ -// import { useChannel } from "@/services/channel.service"; -import { LiveChannelIcon } from "./LiveChannelIcon"; -import { LiveStreamInfo } from "./LiveStreamInfo"; -import { useState } from "react"; - -interface LiveChannelProps { - channelImgLink?: string; - channelName?: string; - altText?: string; - streamTitle?: string; - topicId?: string; - videoId?: string; -} - -export function LiveChannel({ - channelImgLink, - channelName, - altText, - streamTitle, - topicId, - videoId, -}: LiveChannelProps) { - const [isHover, setIsHover] = useState(false); - - return ( -
- - -
- ); -} diff --git a/packages/react/src/components/multiview/LiveChannelIcon.tsx b/packages/react/src/components/multiview/LiveChannelIcon.tsx deleted file mode 100644 index 2237306ee..000000000 --- a/packages/react/src/components/multiview/LiveChannelIcon.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Avatar, AvatarFallback, AvatarImage } from "@/shadcn/ui/avatar"; -import { Dispatch, SetStateAction } from "react"; - -interface LiveChannelIconProps { - imageLink?: string; - channelName?: string; - setIsHover: Dispatch>; -} - -export function LiveChannelIcon({ - imageLink, - channelName, - setIsHover, -}: LiveChannelIconProps) { - return ( -
setIsHover(true)} - onMouseLeave={() => setIsHover(false)} - > - - - CN - -
- 12hr -
-
- ); -} diff --git a/packages/react/src/components/multiview/LiveStreamInfo.tsx b/packages/react/src/components/multiview/LiveStreamInfo.tsx deleted file mode 100644 index 5d43bde45..000000000 --- a/packages/react/src/components/multiview/LiveStreamInfo.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { cn } from "@/lib/utils"; - -interface LiveStreamInfoProps { - thumbnailLink?: string; - altText?: string; - streamTitle?: string; - channelName?: string; - topicId?: string; - isVisible: boolean; -} - -export function LiveStreamInfo({ - thumbnailLink, - altText, - streamTitle, - channelName, - topicId, - isVisible, -}: LiveStreamInfoProps) { - return ( -
-
- {altText} -

- {topicId} -

-
-
-

{streamTitle}

-

{channelName}

-

status

-
-
- ); -} diff --git a/packages/react/src/components/multiview/Multiview.scss b/packages/react/src/components/multiview/Multiview.scss new file mode 100644 index 000000000..fe7c3f25e --- /dev/null +++ b/packages/react/src/components/multiview/Multiview.scss @@ -0,0 +1,5 @@ +$toolbar-height: 64px; + +#multiview { + --toolbar-height: #{$toolbar-height}; +} diff --git a/packages/react/src/components/multiview/Selector.tsx b/packages/react/src/components/multiview/Selector.tsx deleted file mode 100644 index 0929cb757..000000000 --- a/packages/react/src/components/multiview/Selector.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { useEffect, useState } from "react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/shadcn/ui/dropdown-menu"; -import { defaultOrgs } from "../../store/org"; -import { useLive } from "@/services/live.service"; -import { LiveChannel } from "./LiveChannel"; -import { cn } from "../../lib/utils"; - -/** - * ToDos: - * - select favourites - */ - -export function Selector() { - // create a mock favourites object as an org - const Favorites: Org = { - name: "Favorites", - }; - // based on what the selection is -> use different methods to render title card? - const [currentOrg, setCurrentOrg] = useState(Favorites); - const [liveChannels, setLiveChannels] = useState([]); - const { data } = useLive({ org: currentOrg.name }); - - useEffect(() => { - if (!data) return; - setLiveChannels(data.items); - console.log(data); - }, [data]); - - const onSelect = (org: Org) => { - if (org.name === currentOrg.name) return; - setCurrentOrg(org); - setLiveChannels([]); - }; - - return ( -
- - - {currentOrg.name} -
-
- - {[Favorites, ...defaultOrgs].map((org) => { - return ( - onSelect(org)} - > - {org.name} - - ); - })} - -
-
- {liveChannels.map((live) => { - return ( - - ); - })} -
-
- ); -} diff --git a/packages/react/src/components/multiview/ToolButtonContainer.tsx b/packages/react/src/components/multiview/ToolButtonContainer.tsx deleted file mode 100644 index 6ccbe0785..000000000 --- a/packages/react/src/components/multiview/ToolButtonContainer.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function ToolButtonContainer() { - return
placeholder for buttons
; -} diff --git a/packages/react/src/components/multiview/Toolbar.tsx b/packages/react/src/components/multiview/Toolbar.tsx deleted file mode 100644 index c64db3904..000000000 --- a/packages/react/src/components/multiview/Toolbar.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Selector } from "./Selector"; -import { ToolButtonContainer } from "./ToolButtonContainer"; - -export function Toolbar() { - return ( -
-
- -
-
- -
-
- ); -} diff --git a/packages/react/src/components/multiview/background.tsx b/packages/react/src/components/multiview/background.tsx new file mode 100644 index 000000000..3f1091ac1 --- /dev/null +++ b/packages/react/src/components/multiview/background.tsx @@ -0,0 +1,61 @@ +import { useComputedDimensions } from "@/hooks/useComputedDimensions"; +import { isMobileAtom } from "@/hooks/useFrame"; +import { cn } from "@/lib/utils"; +import { useAtomValue } from "jotai"; +import React from "react"; + +interface MultiViewBackgroundProps { + showTips?: boolean; + isFullScreen?: boolean; + style?: React.CSSProperties; + children?: React.ReactNode; +} + +export const MultiViewBackground = ({ + showTips = false, + style = {}, + children, + isFullScreen = false, +}: MultiViewBackgroundProps) => { + const isMobile = useAtomValue(isMobileAtom); + const { cellDimensions } = useComputedDimensions(isFullScreen); + + // Grid background using repeating linear gradients + const backgroundStyle: React.CSSProperties = { + backgroundImage: ` + repeating-linear-gradient( + to right, + #222 0, + #222 1px, + transparent 1px, + transparent ${cellDimensions.columnWidth}px + ), + repeating-linear-gradient( + to bottom, + #222 0, + #222 1px, + transparent 1px, + transparent ${cellDimensions.rowHeight}px + ) + `, + ...style, + }; + + return ( +
+ {showTips && ( +
+ Drag videos here to start your multiview! +
+ )} + {children} +
+ ); +}; diff --git a/packages/react/src/components/multiview/cell/Cell.tsx b/packages/react/src/components/multiview/cell/Cell.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/react/src/components/multiview/cell/Layout.tsx b/packages/react/src/components/multiview/cell/Layout.tsx new file mode 100644 index 000000000..d1aa2495f --- /dev/null +++ b/packages/react/src/components/multiview/cell/Layout.tsx @@ -0,0 +1,81 @@ +import { useComputedDimensions } from "@/hooks/useComputedDimensions"; +import { + isAutoLayoutAtom, + readMultiviewCellsAtom, + updateCellPositionAtom, +} from "@/store/multiview"; +import { useAtomValue, useSetAtom } from "jotai"; +import GridLayout from "react-grid-layout"; +import { VideoCell } from "./video/VideoCell"; +import { Cell } from "@/types/multiview"; +import { useMemo } from "react"; +import { onResize } from "./gridFunctions/resize"; +import { onDragStop } from "./gridFunctions/drag"; + +interface LayoutProps { + isFullScreen?: boolean; +} + +const renderCellContent = (cell: Cell) => { + switch (cell.type) { + case "video": + return ; + case "chat": + return

Chat cell not implemented yet

; + case "placeholder": + return

Placeholder cell not implemented yet

; + default: + return

Unknown cell type

; + } +}; + +export function Layout({ isFullScreen = false }: LayoutProps) { + const { cells } = useAtomValue(readMultiviewCellsAtom); + const { cellDimensions, dimensions } = useComputedDimensions(isFullScreen); + const updateCell = useSetAtom(updateCellPositionAtom); + const isAutoLayout = useAtomValue(isAutoLayoutAtom); + const setIsAutoLayout = useSetAtom(isAutoLayoutAtom); + const turnOffAutoLayout = () => setIsAutoLayout(false); + + const renderedCells = useMemo( + () => + cells.map((cell) => ( +
+ {renderCellContent(cell)} +
+ )), + [cells], + ); + + return ( + onDragStop(layout, oldItem, newItem, updateCell, turnOffAutoLayout)} + onResizeStop={( + layout: GridLayout.Layout[], + oldItem: GridLayout.Layout, + newItem: GridLayout.Layout, + ) => onResize(layout, oldItem, newItem, updateCell, turnOffAutoLayout, 2)} + > + {renderedCells} + + ); +} diff --git a/packages/react/src/components/multiview/cell/chat/ChatCell.tsx b/packages/react/src/components/multiview/cell/chat/ChatCell.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/react/src/components/multiview/cell/gridFunctions/GridFunctions.ts b/packages/react/src/components/multiview/cell/gridFunctions/GridFunctions.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/react/src/components/multiview/cell/gridFunctions/common.ts b/packages/react/src/components/multiview/cell/gridFunctions/common.ts new file mode 100644 index 000000000..113ffc6d8 --- /dev/null +++ b/packages/react/src/components/multiview/cell/gridFunctions/common.ts @@ -0,0 +1,195 @@ +import { Layout } from "react-grid-layout"; + +export type Direction = "l" | "r" | "u" | "d"; + +export function singleDirectionResize( + directionImpacted: Direction, + collidingItem: Layout, + newItem: Layout, + minSize: number, + layout: Layout[], + updateCellInStorage: (cellId: string, updates: Partial) => void, +) { + let collidingLength: number; + switch (directionImpacted) { + case "l": + collidingLength = + collidingItem.w - (collidingItem.x + collidingItem.w - newItem.x); + if (collidingLength < minSize) { + collidingItem.y = newItem.y + newItem.h; + } else { + collidingItem.w -= collidingItem.x + collidingItem.w - newItem.x; + } + updateCellInStorage(collidingItem.i, { + x: collidingItem.x, + y: collidingItem.y, + w: collidingItem.w, + h: collidingItem.h, + }); + break; + case "r": + collidingLength = newItem.x + newItem.w - collidingItem.x; + if (collidingItem.w - collidingLength < minSize) { + collidingItem.y = newItem.y + newItem.h; + } else { + collidingItem.w = collidingItem.w - collidingLength; + collidingItem.x = newItem.x + newItem.w; + } + updateCellInStorage(collidingItem.i, { + x: collidingItem.x, + y: collidingItem.y, + w: collidingItem.w, + h: collidingItem.h, + }); + break; + case "u": + collidingLength = newItem.y - collidingItem.y; + if (collidingLength < minSize) { + collidingItem.y = newItem.y + newItem.h; + } else { + collidingItem.h -= collidingItem.y + collidingItem.h - newItem.y; + } + updateCellInStorage(collidingItem.i, { + x: collidingItem.x, + y: collidingItem.y, + w: collidingItem.w, + h: collidingItem.h, + }); + break; + case "d": + collidingLength = + collidingItem.h - (newItem.y + newItem.h - collidingItem.y); + if (collidingLength < minSize) { + collidingItem.y = newItem.y + newItem.h; + } else { + collidingItem.h -= newItem.y + newItem.h - collidingItem.y; + collidingItem.y = newItem.y + newItem.h; + } + updateCellInStorage(collidingItem.i, { + x: collidingItem.x, + y: collidingItem.y, + w: collidingItem.w, + h: collidingItem.h, + }); + break; + } + moveCollidingItems(layout, collidingItem, updateCellInStorage); +} + +function areItemsColliding(itemA: Layout, itemB: Layout): boolean { + return ( + itemA.x < itemB.x + itemB.w && + itemA.x + itemA.w > itemB.x && + itemA.y < itemB.y + itemB.h && + itemA.y + itemA.h > itemB.y + ); +} + +export function moveCollidingItems( + itemsToCheck: Layout[], + movingItem: Layout, + updateCellInStorage: (cellId: string, updates: Partial) => void, +) { + const itemsCollidingWithOldSpace = getCollidingItems( + itemsToCheck, + movingItem, + ); + + if (itemsCollidingWithOldSpace.length > 0) { + // check if the moving Item's top left corner is below the half way point of the first colliding item + const firstItemCollidedWith = itemsCollidingWithOldSpace[0]; + // if the item's new position is more than 1/3 lower than the first colliding item, move the new item down + if (firstItemCollidedWith.y + firstItemCollidedWith.h / 3 <= movingItem.y) { + movingItem.y = firstItemCollidedWith.y + firstItemCollidedWith.h; + updateCellInStorage(movingItem.i, { + x: movingItem.x, + y: movingItem.y, + w: movingItem.w, + h: movingItem.h, + }); + moveCollidingItems(itemsToCheck, movingItem, updateCellInStorage); + } else { + for (const item of itemsCollidingWithOldSpace) { + // there is a collision, we need to find the next available place VERTICALLY + if (areItemsColliding(item, movingItem)) { + item.y = movingItem.y + movingItem.h; + updateCellInStorage(item.i, { + x: item.x, + y: item.y, + w: item.w, + h: item.h, + }); + moveCollidingItems(itemsToCheck, item, updateCellInStorage); + } + } + } + } +} + +export function getCollidingItems(layout: Layout[], newItem: Layout): Layout[] { + const itemsCollidingWithOldSpace = layout + .filter((item) => { + return ( + item.i !== newItem.i && + item.x < newItem.x + newItem.w && + item.x + item.w > newItem.x && + item.y < newItem.y + newItem.h && + item.y + item.h > newItem.y + ); + }) + .sort((a, b) => { + // this sort will ensure that the items colliding will be sorted by the top left first + if (a.y === b.y) { + return a.x - b.x; // Sort by x position when y is the same + } + return a.y - b.y; // Sort by y position to handle vertical collisions + }); + return itemsCollidingWithOldSpace; +} + +export function checkImpactDirection( + newSpace: Layout, + oldSpace: Layout, +): Direction[] { + const impacts: Direction[] = []; + const impactDirection = { + x: + newSpace.x - oldSpace.x === 0 + ? newSpace.w - oldSpace.w + : newSpace.x - oldSpace.x, + y: + newSpace.y - oldSpace.y === 0 + ? newSpace.h - oldSpace.h + : newSpace.y - oldSpace.y, + }; + + if (impactDirection.x < 0) { + impacts.push("l"); + } else if (impactDirection.x > 0) { + impacts.push("r"); + } + + if (impactDirection.y < 0) { + impacts.push("u"); + } else if (impactDirection.y > 0) { + impacts.push("d"); + } + return impacts; +} + +export function registerMovedcell( + movedCell: Layout, + spaceMovedTo: Layout, + updateCellInStorage: (cellId: string, updates: Partial) => void, +) { + movedCell.x = spaceMovedTo.x; + movedCell.y = spaceMovedTo.y; + movedCell.w = spaceMovedTo.w; + movedCell.h = spaceMovedTo.h; + updateCellInStorage(movedCell.i, { + x: movedCell.x, + y: movedCell.y, + w: movedCell.w, + h: movedCell.h, + }); +} diff --git a/packages/react/src/components/multiview/cell/gridFunctions/drag.ts b/packages/react/src/components/multiview/cell/gridFunctions/drag.ts new file mode 100644 index 000000000..47e8b8fc4 --- /dev/null +++ b/packages/react/src/components/multiview/cell/gridFunctions/drag.ts @@ -0,0 +1,258 @@ +import { Layout } from "react-grid-layout"; +import { getCollidingItems, registerMovedcell } from "./common"; +import { Cell } from "@/types/multiview"; + +/* Expected behavior: +There are three possible scenarios post dragging items +1) items are swapped +2) Item is moved to the new space without any collision +3) Item is grows into empty space - only when at least one corner matches a respective corner of the empty space +*/ +export function onDragStop( + layout: Layout[], + oldItem: Layout, + newItem: Layout, + updateCellInStorage: (cellId: string, updates: Partial) => void, + turnOffAutoLayout: () => void, +) { + // find all of the items that are colliding with the newItem position + const collidingItems = getCollidingItems(layout, newItem); + + if (collidingItems.length === 0) { + const itemsNotMoved = layout.filter((item) => item.i !== newItem.i); + const maxHOccupied = Math.max(...layout.map((item) => item.h + item.y), 24); + + const emptyCell = findEmptyCellToFill(itemsNotMoved, maxHOccupied, newItem); + + if (emptyCell) { + registerMovedcell(newItem, emptyCell, updateCellInStorage); + turnOffAutoLayout(); + return; + } + } else if (collidingItems.length === 1) { + const swapTarget = collidingItems[0]; + if ( + (swapTarget.x === newItem.x && swapTarget.y === newItem.y) || + collidingItems.length === 1 + ) { + registerMovedcell(newItem, swapTarget, updateCellInStorage); + registerMovedcell(swapTarget, oldItem, updateCellInStorage); + turnOffAutoLayout(); + return; + } + } else { + newItem.x = oldItem.x; + newItem.y = oldItem.y; + newItem.w = oldItem.w; + newItem.h = oldItem.h; + } +} + +// recalculates the layout of the cells based on the current cell positions +function findEmptyCellToFill( + placedCellsPositions: Layout[], + maxHeight: number, + newItem: Layout, + matchingCorner: boolean = true, +) { + const emptyCells = calculateEmptyCells( + placedCellsPositions, + 24, + maxHeight, + ).filter((cell) => { + return cell.h >= 2 && cell.w >= 2; // Only include cells that are larger than 2x2 + }); + + const newItemCorners = { + topLeft: { x: newItem.x, y: newItem.y }, + topRight: { x: newItem.x + newItem.w, y: newItem.y }, + bottomLeft: { x: newItem.x, y: newItem.y + newItem.h }, + bottomRight: { + x: newItem.x + newItem.w, + y: newItem.y + newItem.h, + }, + }; + + if (matchingCorner) { + return emptyCells.find((cell) => { + const emptyCellCorners = { + topLeft: { x: cell.x, y: cell.y }, + topRight: { x: cell.x + cell.w, y: cell.y }, + bottomLeft: { x: cell.x, y: cell.y + cell.h }, + bottomRight: { x: cell.x + cell.w, y: cell.y + cell.h }, + }; + + return ( + // Top-left corner match + (newItemCorners.topLeft.x === emptyCellCorners.topLeft.x && + newItemCorners.topLeft.y === emptyCellCorners.topLeft.y) || + // Top-right corner match + (newItemCorners.topRight.x === emptyCellCorners.topRight.x && + newItemCorners.topRight.y === emptyCellCorners.topRight.y) || + // Bottom-left corner match + (newItemCorners.bottomLeft.x === emptyCellCorners.bottomLeft.x && + newItemCorners.bottomLeft.y === emptyCellCorners.bottomLeft.y) || + // Bottom-right corner match + (newItemCorners.bottomRight.x === emptyCellCorners.bottomRight.x && + newItemCorners.bottomRight.y === emptyCellCorners.bottomRight.y) + ); + }); + } + return emptyCells.find((cell) => { + return ( + newItem.x >= cell.x && + newItem.x + newItem.w <= cell.x + cell.w && + newItem.y >= cell.y && + newItem.y + newItem.h <= cell.y + cell.h + ); + }); +} + +export function calculateEmptyCells( + occupiedCells: Layout[], + gridWidth: number, + gridHeight: number, +): Cell[] { + const occupancyGrid: boolean[][] = Array(gridHeight) + .fill(null) + .map(() => Array(gridWidth).fill(false)); + + occupiedCells.forEach((cell) => { + for (let y = cell.y; y < cell.y + cell.h; y++) { + for (let x = cell.x; x < cell.x + cell.w; x++) { + if (y < gridHeight && x < gridWidth) { + occupancyGrid[y][x] = true; + } + } + } + }); + + // Find empty rectangular regions + const emptyCells: Cell[] = []; + const processedGrid: boolean[][] = Array(gridHeight) + .fill(null) + .map(() => Array(gridWidth).fill(false)); + + for (let y = 0; y < gridHeight; y++) { + for (let x = 0; x < gridWidth; x++) { + if (!occupancyGrid[y][x] && !processedGrid[y][x]) { + const emptyRegion = findLargestEmptyRegion( + occupancyGrid, + processedGrid, + x, + y, + gridWidth, + gridHeight, + ); + + if (emptyRegion) { + emptyCells.push({ + i: `empty-${x}-${y}`, + type: "placeholder", + x: emptyRegion.x, + y: emptyRegion.y, + w: emptyRegion.w, + h: emptyRegion.h, + }); + } + } + } + } + + return emptyCells; +} + +// always looks for the largest empty rectangle possible from the empty cells +function findLargestEmptyRegion( + occupancyGrid: boolean[][], + processedGrid: boolean[][], + startX: number, + startY: number, + gridWidth: number, + gridHeight: number, +): { x: number; y: number; w: number; h: number } | null { + let maxArea = 0; + let bestRegion: { x: number; y: number; w: number; h: number } | null = null; + + // Create height array for histogram approach + const heights: number[] = new Array(gridWidth - startX).fill(0); + + for (let y = startY; y < gridHeight; y++) { + // Update heights array + for (let x = startX; x < gridWidth; x++) { + const idx = x - startX; + if (occupancyGrid[y][x] || processedGrid[y][x]) { + heights[idx] = 0; + } else { + heights[idx]++; + } + } + + const result = largestRectangleInHistogram(heights, startX, y); + if (result && result.area > maxArea) { + maxArea = result.area; + bestRegion = { + x: result.x, + y: result.y, + w: result.w, + h: result.h, + }; + } + } + + if (!bestRegion) return null; + + // Mark the best region as processed + for (let y = bestRegion.y; y < bestRegion.y + bestRegion.h; y++) { + for (let x = bestRegion.x; x < bestRegion.x + bestRegion.w; x++) { + processedGrid[y][x] = true; + } + } + + return bestRegion; +} + +function largestRectangleInHistogram( + heights: number[], + baseX: number, + currentY: number, +): { x: number; y: number; w: number; h: number; area: number } | null { + const stack: number[] = []; + let maxArea = 0; + let bestRect: { + x: number; + y: number; + w: number; + h: number; + area: number; + } | null = null; + + for (let i = 0; i <= heights.length; i++) { + const currentHeight = i === heights.length ? 0 : heights[i]; + + while ( + stack.length > 0 && + heights[stack[stack.length - 1]] > currentHeight + ) { + const height = heights[stack.pop()!]; + const width = stack.length === 0 ? i : i - stack[stack.length - 1] - 1; + const area = height * width; + + if (area > maxArea) { + maxArea = area; + const startX = stack.length === 0 ? 0 : stack[stack.length - 1] + 1; + bestRect = { + x: baseX + startX, + y: currentY - height + 1, + w: width, + h: height, + area: area, + }; + } + } + + stack.push(i); + } + + return bestRect; +} diff --git a/packages/react/src/components/multiview/cell/gridFunctions/resize.ts b/packages/react/src/components/multiview/cell/gridFunctions/resize.ts new file mode 100644 index 000000000..ecbe1c2da --- /dev/null +++ b/packages/react/src/components/multiview/cell/gridFunctions/resize.ts @@ -0,0 +1,50 @@ +import { Layout } from "react-grid-layout"; +import { + checkImpactDirection, + getCollidingItems, + singleDirectionResize, +} from "./common"; + +export function onResize( + layout: Layout[], + oldItem: Layout, + newItem: Layout, + updateCellInStorage: (cellId: string, updates: Partial) => void, + turnOffAutoLayout: () => void, + minSize: number = 2, + limit: number = 24, +) { + const directionImpacted = checkImpactDirection(newItem, oldItem); + + // if the newItem is smaller than the minSize, then we need to adjust it + // if the resizing brings the item to beyond the edge of the grid, then we need to adjust the x position + if (newItem.w < minSize) { + newItem.w = minSize; + if (newItem.x + newItem.w > limit) { + newItem.x = limit - newItem.w; + } + } + if (newItem.h < minSize) { + newItem.h = minSize; + } + turnOffAutoLayout(); + updateCellInStorage(newItem.i, { + x: newItem.x, + y: newItem.y, + w: newItem.w, + h: newItem.h, + }); + + const itemsColliding = getCollidingItems(layout, newItem); + + for (const collidingItem of itemsColliding) { + singleDirectionResize( + directionImpacted[0], + collidingItem, + newItem, + minSize, + layout, + updateCellInStorage, + ); + } +} diff --git a/packages/react/src/components/multiview/cell/placeholder/PlaceHolderCell.tsx b/packages/react/src/components/multiview/cell/placeholder/PlaceHolderCell.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/react/src/components/multiview/cell/video/VideoCell.tsx b/packages/react/src/components/multiview/cell/video/VideoCell.tsx new file mode 100644 index 000000000..e802e46b5 --- /dev/null +++ b/packages/react/src/components/multiview/cell/video/VideoCell.tsx @@ -0,0 +1,40 @@ +import { PlayerWrapper } from "@/components/layout/PlayerWrapper"; +import { cn, idToVideoURL } from "@/lib/utils"; +import { videoStatusAtomFamily } from "@/store/player"; +import { useAtomValue } from "jotai"; +import { Suspense } from "react"; +import { VideoCellControl } from "./VideoCellControl"; + +interface VideoCellProps { + id: string; +} + +export function VideoCell({ id }: VideoCellProps) { + const videoStatusAtom = videoStatusAtomFamily(id || "x"); + const statusValue = useAtomValue(videoStatusAtom); + + return ( + <> +
+ }> + + +
+ + + ); +} + +const VideoSkeleton = () => ( +
+
Loading video...
+
+); diff --git a/packages/react/src/components/multiview/cell/video/VideoCellControl.tsx b/packages/react/src/components/multiview/cell/video/VideoCellControl.tsx new file mode 100644 index 000000000..078b62896 --- /dev/null +++ b/packages/react/src/components/multiview/cell/video/VideoCellControl.tsx @@ -0,0 +1,56 @@ +import { cn } from "@/lib/utils"; +import { Button } from "@/shadcn/ui/button"; +import { + mutateVideoToPlaceholderAtom, + readMultiviewCellsAtom, + removeVideoCellAtom, +} from "@/store/multiview"; +import { videoStatusAtomFamily } from "@/store/player"; +import { useAtomValue, useSetAtom } from "jotai"; + +interface VideoCellControlProps { + id: string; +} + +export function VideoCellControl({ id }: VideoCellControlProps) { + const switchToPlaceholder = useSetAtom(mutateVideoToPlaceholderAtom); + const removeVideo = useSetAtom(removeVideoCellAtom); + const videoStatusAtom = videoStatusAtomFamily(id || "x"); + const statusValue = useAtomValue(videoStatusAtom); + const { cells } = useAtomValue(readMultiviewCellsAtom); + const cell = cells.find((cell) => cell.i === `video_${id}`); + const dimensions = `${cell?.w} x ${cell?.h}`; + + return ( +
+ +
{dimensions}
+ +
+ ); +} diff --git a/packages/react/src/components/multiview/todo.md b/packages/react/src/components/multiview/todo.md new file mode 100644 index 000000000..8eb4d36cb --- /dev/null +++ b/packages/react/src/components/multiview/todo.md @@ -0,0 +1,7 @@ +# TODO +## Understanding the Vue counterpart +- Do +## Video cell +- Each cell has a control panel +- That control panel needs to disappear when the video is not playing +- retoggled by movement \ No newline at end of file diff --git a/packages/react/src/components/multiview/toolbar/LiveChannel.tsx b/packages/react/src/components/multiview/toolbar/LiveChannel.tsx new file mode 100644 index 000000000..7a0269967 --- /dev/null +++ b/packages/react/src/components/multiview/toolbar/LiveChannel.tsx @@ -0,0 +1,77 @@ +import { usePreferredName } from "@/store/settings"; +import { + TooltipProvider, + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@radix-ui/react-tooltip"; +import { Avatar, AvatarFallback, AvatarImage } from "@/shadcn/ui/avatar"; +import { cn, makeThumbnailUrl } from "@/lib/utils"; +import { MemoizedLiveChannelTooltipContentCard } from "./LiveChannelTooltipContentCard"; +import { compareTimeDiffToNow } from "@/lib/time"; +import { isAutoLayoutAtom, registerVideoCellAtom } from "@/store/multiview"; +import { useAtom, useSetAtom } from "jotai"; + +interface LiveChannelProps { + video: VideoBase; +} + +export function LiveChannel({ video }: LiveChannelProps) { + const preferredName = usePreferredName({ + name: video.channel.name, + english_name: video.channel.english_name, + }); + + const thumbnail = makeThumbnailUrl(video.id, "sm"); + const [_, addVideo] = useAtom(registerVideoCellAtom); + const setAutoLayoutAtom = useSetAtom(isAutoLayoutAtom); + const resetAutoLayout = () => setAutoLayoutAtom(false); + + // TODO: move live stream info card outside of this components + return ( + + + +
{ + addVideo(video); + resetAutoLayout(); + }} + > + + + CN + +
+ {/* if live stream has started, check how long it has been running */} + {/* if it is less than 1 hour, use the minutes, otherwise, round down to the hour */} + {video.status === "live" + ? compareTimeDiffToNow(video.start_actual) + : compareTimeDiffToNow(video.start_scheduled)} +
+
+
+ + + +
+
+ ); +} diff --git a/packages/react/src/components/multiview/toolbar/LiveChannelTooltipContentCard.tsx b/packages/react/src/components/multiview/toolbar/LiveChannelTooltipContentCard.tsx new file mode 100644 index 000000000..358bdd8ea --- /dev/null +++ b/packages/react/src/components/multiview/toolbar/LiveChannelTooltipContentCard.tsx @@ -0,0 +1,48 @@ +import { VideoCardCountdownToLive } from "@/components/video/VideoCardCountdownToLive"; +import { VideoThumbnail } from "@/components/video/VideoThumbnail"; +import { cn } from "@/lib/utils"; +import React from "react"; + +export const MemoizedLiveChannelTooltipContentCard = React.memo( + LiveChannelTooltipContentCard, +); + +export function LiveChannelTooltipContentCard({ + video, + thumbnail, + preferredName, +}: { + video: VideoBase; + thumbnail: string | string[]; + preferredName: string | undefined; +}) { + return ( + <> +
+
+ + {video.topic_id && ( + + {video.topic_id.replaceAll("_", " ")} + + )} +
+
+
+ {video.title} +
+
+ {preferredName} +
+
+ +
+
+
+ + ); +} diff --git a/packages/react/src/components/multiview/toolbar/ToolBar.tsx b/packages/react/src/components/multiview/toolbar/ToolBar.tsx new file mode 100644 index 000000000..c7d4cc736 --- /dev/null +++ b/packages/react/src/components/multiview/toolbar/ToolBar.tsx @@ -0,0 +1,142 @@ +import { cn } from "@/lib/utils"; +import { ToolButtonContainer } from "./ToolButtonContainer"; +import { + isMobileAtom, + isSidebarOpenAtom, + multiViewPanelOpenAtom, + sidebarShouldBeFullscreenAtom, +} from "@/hooks/useFrame"; +import { useAtom, useAtomValue } from "jotai"; +// import { useTranslation } from "react-i18next"; +import { defaultOrgs } from "@/store/org"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shadcn/ui/dropdown-menu"; +import { LiveChannel } from "./LiveChannel"; +import { useLive } from "@/services/live.service"; +import { useRef, useState } from "react"; +import { useVideoFilter } from "@/hooks/useVideoFilter"; +import { useVideoSort } from "@/hooks/useVideoSort"; +import { MultiViewIcon } from "./ToolButton"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/shadcn/ui/button"; + +type VideoWithExtra = VideoBase & { + platform: string; +}; + +export function ToolBar({ + icons, + currentVideoIds = [], +}: { + icons: MultiViewIcon[]; + currentVideoIds: string[]; +}) { + const { t } = useTranslation(); + const [open] = useAtom(isSidebarOpenAtom); + const [isFullScreen] = useAtom(sidebarShouldBeFullscreenAtom); + const [isBarActive] = useAtom(multiViewPanelOpenAtom); + const isMobile = useAtomValue(isMobileAtom); + + // create a mock favourites object as an org + const Favorites: Org = { + name: "Favorites", + }; + // based on what the selection is -> use different methods to render title card? + const [currentOrg, setCurrentOrg] = useState(Favorites); + const { data: live } = useLive({ + org: currentOrg.name, + type: ["stream"], + }); + + const liveChannelContainerRef = useRef(null); + + const liveStreamsByOrg = useVideoFilter( + live?.items as VideoBase[], + "stream_schedule", + "org", + ); + + const nowLiveSortedWithPlatform: VideoWithExtra[] = useVideoSort( + liveStreamsByOrg, + "stream_schedule", + ) + // check against videos that are already viewing in the multiview + .filter((live) => !currentVideoIds.includes(live.id)) + .map((video) => ({ + ...video, + // add platform info + platform: (video as VideoWithExtra).platform ?? "", + })); + + const onSelect = (org: Org) => { + if (org.name === currentOrg.name) return; + setCurrentOrg(org); + }; + + const handleWheel = (e: React.WheelEvent) => { + if (liveChannelContainerRef.current) { + if (e.deltaY !== 0) { + liveChannelContainerRef.current.scrollLeft += e.deltaY; + e.preventDefault(); + } + } + }; + + return ( +
+ {!isMobile && ( + <> + + + + + + {[Favorites, ...defaultOrgs].map((org) => { + return ( + onSelect(org)} + > + {t(org.name)} + + ); + })} + + +
+ {nowLiveSortedWithPlatform.map((live) => { + return ; + })} +
+ + )} + +
+ ); +} diff --git a/packages/react/src/components/multiview/toolbar/ToolButton.tsx b/packages/react/src/components/multiview/toolbar/ToolButton.tsx new file mode 100644 index 000000000..b3fa2bdf8 --- /dev/null +++ b/packages/react/src/components/multiview/toolbar/ToolButton.tsx @@ -0,0 +1,47 @@ +import { cn } from "@/lib/utils"; +import { Button } from "@/shadcn/ui/button"; +import { + TooltipProvider, + Tooltip, + TooltipTrigger, + TooltipContent, +} from "@radix-ui/react-tooltip"; + +export type MultiViewIcon = { + path: string; + tooltip: string; + onClick?: () => void; +}; + +export function ToolButton({ + icon, + index, + className = "", +}: { + icon: MultiViewIcon; + index?: string; + className?: string; +}) { + return ( + + + + + + + {icon.tooltip} + + + + ); +} diff --git a/packages/react/src/components/multiview/toolbar/ToolButtonContainer.tsx b/packages/react/src/components/multiview/toolbar/ToolButtonContainer.tsx new file mode 100644 index 000000000..f4e3f0f6e --- /dev/null +++ b/packages/react/src/components/multiview/toolbar/ToolButtonContainer.tsx @@ -0,0 +1,11 @@ +import { MultiViewIcon, ToolButton } from "./ToolButton"; + +export function ToolButtonContainer({ icons }: { icons: MultiViewIcon[] }) { + return ( +
+ {icons.map((icon, index) => ( + + ))} +
+ ); +} diff --git a/packages/react/src/hooks/useComputedDimensions.ts b/packages/react/src/hooks/useComputedDimensions.ts new file mode 100644 index 000000000..ff65a61a3 --- /dev/null +++ b/packages/react/src/hooks/useComputedDimensions.ts @@ -0,0 +1,61 @@ +import { useAtomValue } from "jotai"; +import { useState, useEffect } from "react"; +import { isSidebarOpenAtom, multiViewPanelOpenAtom } from "./useFrame"; + +const HEADER_HEIGHT = 60; +const TOOLBAR_HEIGHT = 64; +const SIDEBAR_WIDTH = 208; +const CELL_COUNT = 24; + +// the computed dimensions here is based on screen dimension and should not be impacted by the content size +export const useComputedDimensions = (isFullScreen = false) => { + const isSidebarOpen = useAtomValue(isSidebarOpenAtom); + const isBarActive = useAtomValue(multiViewPanelOpenAtom); + + const [dimensions, setDimensions] = useState({ + width: 0, + height: 0, + }); + + const [cellDimensions, setCellDimensions] = useState({ + columnWidth: 0, + rowHeight: 0, + }); + + useEffect(() => { + const updateDimensions = () => { + const height = + window.innerHeight - + (isFullScreen + ? isBarActive + ? TOOLBAR_HEIGHT + : 0 + : isBarActive + ? TOOLBAR_HEIGHT + HEADER_HEIGHT + : HEADER_HEIGHT); + const width = isSidebarOpen + ? window.innerWidth - SIDEBAR_WIDTH + : window.innerWidth; + + setCellDimensions({ + columnWidth: Math.max(width / CELL_COUNT, 1), + rowHeight: Math.max(height / CELL_COUNT, 1), + }); + + setDimensions({ + width, + height, + }); + }; + + updateDimensions(); + // Listen for resize events + window.addEventListener("resize", updateDimensions); + return () => window.removeEventListener("resize", updateDimensions); + }, [isFullScreen, isBarActive, isSidebarOpen]); + + return { + cellDimensions, + dimensions, + }; +}; diff --git a/packages/react/src/hooks/useFrame.ts b/packages/react/src/hooks/useFrame.ts index 14c8c8e16..408007e9a 100644 --- a/packages/react/src/hooks/useFrame.ts +++ b/packages/react/src/hooks/useFrame.ts @@ -13,6 +13,8 @@ export const pageIsFullscreenAtom = atom(false); export const siteIsSmallAtom = atom(window.innerWidth < MobileSizeBreak); +export const multiViewPanelOpenAtom = atom(true); + export const sidebarShouldBeFullscreenAtom = atom( window.innerWidth < FooterSizeBreak, ); @@ -28,6 +30,18 @@ export const isMobileAtom = atom( export const isSidebarOpenAtom = atom(window.innerWidth > MobileSizeBreak); +export const isMultiViewPanelOpenAtom = atom((get) => + get(multiViewPanelOpenAtom), +); + +export const openMultiViewPanelAtom = atom(null, (_, set) => { + set(multiViewPanelOpenAtom, true); +}); + +export const closeMultiViewPanelAtom = atom(null, (_, set) => { + set(multiViewPanelOpenAtom, false); +}); + export const indicatePageFullscreenAtom = atom( null, (get, set, pageIsFullscreen: boolean) => { diff --git a/packages/react/src/hooks/useVideoStatus.ts b/packages/react/src/hooks/useVideoStatus.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/react/src/hooks/useVideoStatusControl.ts b/packages/react/src/hooks/useVideoStatusControl.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/react/src/lib/time.ts b/packages/react/src/lib/time.ts index d0d3150ce..3ddb09f4d 100644 --- a/packages/react/src/lib/time.ts +++ b/packages/react/src/lib/time.ts @@ -15,3 +15,17 @@ export function formatDuration(millisecs: number): string { return millisecs < 0 ? `-${formattedTime}` : formattedTime; } + +export function compareTimeDiffToNow(timeString: string | undefined) { + if (!timeString) return ""; + const diff = new Date(timeString).getTime() - new Date().getTime(); + const absDiff = Math.abs(diff); + const seconds = Math.floor(absDiff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours < 1) { + return `${minutes}m`; + } + return `${hours}h`; +} diff --git a/packages/react/src/main.tsx b/packages/react/src/main.tsx index 1720ac411..2eff36fcf 100644 --- a/packages/react/src/main.tsx +++ b/packages/react/src/main.tsx @@ -4,6 +4,8 @@ import { GoogleOAuthProvider } from "@react-oauth/google"; import { HelmetProvider } from "@dr.pogodin/react-helmet"; import "./index.css"; import "./colors.css"; +import "../node_modules/react-grid-layout/css/styles.css"; +import "../node_modules/react-resizable/css/styles.css"; import "uno.css"; import { QueryClientProvider } from "@tanstack/react-query"; import "./lib/i18n"; diff --git a/packages/react/src/routes/multiview/multiview.tsx b/packages/react/src/routes/multiview/multiview.tsx index 76e10dafd..672cb4a79 100644 --- a/packages/react/src/routes/multiview/multiview.tsx +++ b/packages/react/src/routes/multiview/multiview.tsx @@ -1,21 +1,130 @@ -import { Toolbar } from "@/components/multiview/Toolbar"; +import { ToolBar } from "@/components/multiview/toolbar/ToolBar"; +import { + MultiViewIcon, + ToolButton, +} from "@/components/multiview/toolbar/ToolButton"; +import { + closeMultiViewPanelAtom, + isMobileAtom, + isSidebarOpenAtom, + multiViewPanelOpenAtom, + openMultiViewPanelAtom, +} from "@/hooks/useFrame"; +import { + clearMultiviewCellsAtom, + readMultiviewCellsAtom, + useMultiViewFullScreen, +} from "@/store/multiview"; +import { cn } from "@/lib/utils"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useRef } from "react"; import { Helmet } from "@dr.pogodin/react-helmet"; +import { MultiViewBackground } from "@/components/multiview/background"; +import "../../components/multiview/Multiview.scss"; +import { Cell, VideoCell } from "@/types/multiview"; +import { Layout } from "@/components/multiview/cell/Layout"; // multiview skeleton // selection bar at the top to change between orgs and allow url insertion // grid page for drag and drop export function Multiview() { + const multiviewRef = useRef(null); + + const isBarActive = useAtomValue(multiViewPanelOpenAtom); + const isMobile = useAtomValue(isMobileAtom); + const isSidebarOpen = useAtomValue(isSidebarOpenAtom); + const { cells } = useAtomValue(readMultiviewCellsAtom); + + const openPanel = useSetAtom(openMultiViewPanelAtom); + const closePanel = useSetAtom(closeMultiViewPanelAtom); + const clearCells = useSetAtom(clearMultiviewCellsAtom); + + const { isFullScreen: isFullscreen, toggleFullScreen } = + useMultiViewFullScreen(multiviewRef); + + const baseIcons: MultiViewIcon[] = [ + { path: "i-heroicons:plus-circle", tooltip: "Select Live" }, + { path: "i-heroicons:squares-2x2", tooltip: "Change Layout" }, + { path: "i-heroicons:squares-plus", tooltip: "Add Cell" }, + { path: "i-heroicons:adjustments-vertical", tooltip: "Media Control" }, + { path: "i-heroicons:rectangle-group", tooltip: "Reorder Layout" }, + ]; + + const additionalIcons: MultiViewIcon[] = [ + { path: "i-heroicons:link", tooltip: "Share Layout" }, + { + path: "i-heroicons:chevron-up", + tooltip: "Collapse Panel", + onClick: closePanel, + }, + ]; + + const icons: MultiViewIcon[] = [ + ...baseIcons, + { path: "i-heroicons:arrow-path", tooltip: "Archive Sync" }, + { path: "i-fluent:save-32-regular", tooltip: "Save Layout" }, + { path: "i-heroicons:trash", tooltip: "Clear", onClick: clearCells }, + { + path: isFullscreen + ? "i-heroicons:arrows-pointing-in" + : "i-heroicons:arrows-pointing-out", + tooltip: isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen", + onClick: toggleFullScreen, + }, + ...additionalIcons, + ]; + + const mobileIcons: MultiViewIcon[] = [...baseIcons, ...additionalIcons]; + return ( <> Multiview - Holodex
- +
+ c.type === "video") + .map((c: VideoCell) => c.i.replace("video_", ""))} + /> + +
+
+ + {cells.length > 0 && } +
); diff --git a/packages/react/src/routes/settings/user.tsx b/packages/react/src/routes/settings/user.tsx index cbf457f9b..836cc9f99 100644 --- a/packages/react/src/routes/settings/user.tsx +++ b/packages/react/src/routes/settings/user.tsx @@ -38,7 +38,7 @@ export function SettingsUser() {
{user.role}
diff --git a/packages/react/src/store/multiview.ts b/packages/react/src/store/multiview.ts new file mode 100644 index 000000000..6dccb03e8 --- /dev/null +++ b/packages/react/src/store/multiview.ts @@ -0,0 +1,307 @@ +import { indicatePageFullscreenAtom } from "@/hooks/useFrame"; +import { + Cell, + ChatCell, + ChatCellStatus, + MultiviewCells, + PlaceholderCell, + VideoCell, +} from "@/types/multiview"; +import { atom, useAtom, useSetAtom } from "jotai"; +import { RefObject, useEffect } from "react"; + +export const isMultiViewFullscreenAtom = atom(!!document.fullscreenElement); + +export function useMultiViewFullScreen(ref: RefObject) { + const [isFullScreen, setIsFullScreen] = useAtom(isMultiViewFullscreenAtom); + const indicatePageFullscreen = useSetAtom(indicatePageFullscreenAtom); + + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullScreen(!!document.fullscreenElement); + indicatePageFullscreen(!!document.fullscreenElement); + }; + + document.addEventListener("fullscreenchange", handleFullscreenChange); + + return () => { + document.removeEventListener("fullscreenchange", handleFullscreenChange); + }; + }, [setIsFullScreen, indicatePageFullscreen]); + + const toggleFullScreen = () => { + if (ref && ref.current) { + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + ref.current.requestFullscreen(); // Use ref.current directly + } + } + }; + + return { + isFullScreen, + toggleFullScreen, + }; +} + +// TODO: read from memory +export const multiviewCellsAtom = atom({ cells: [] }); +export const isAutoLayoutAtom = atom(false); + +export const readMultiviewCellsAtom = atom((get) => get(multiviewCellsAtom)); + +export const removeMultiviewCellAtom = atom( + null, + (get, set, cellId: string) => { + const curr = get(readMultiviewCellsAtom); + set(multiviewCellsAtom, { + cells: curr.cells.filter((cell) => cell.i !== cellId), + }); + }, +); + +export const clearMultiviewCellsAtom = atom(null, (_, set) => { + set(multiviewCellsAtom, { cells: [] }); +}); + +export const setCellsAtom = atom(null, (_, set, cells: Cell[]) => { + set(multiviewCellsAtom, { cells: cells }); +}); + +function calculateLayout( + cells: Cell[], + maxCol: number = 24, + maxRow: number = 24, +) { + if (cells.length === 0) return []; + + const numberOfCells = cells.length; + const rows = Math.floor(Math.sqrt(numberOfCells)); + const cols = Math.ceil(numberOfCells / rows); + + // Calculate grid units (each cell should span equal portions of the 24x24 grid) + const cellWidth = Math.floor(maxCol / cols); + const cellHeight = Math.floor(maxRow / rows); + + const sortedCells = cells.toSorted((a, b) => + a.y !== b.y ? a.y - b.y : a.x - b.x, + ); + + return sortedCells.map((cell, i) => ({ + i: cell.i, + x: (i % cols) * cellWidth, + y: Math.floor(i / cols) * cellHeight, + w: cellWidth, + h: cellHeight, + })); +} + +function applyCalculatedPositions(cells: Cell[]): Cell[] { + if (cells.length === 0) return []; + + const newPositions = calculateLayout(cells); + + return cells.map((cell) => { + const position = newPositions.find((pos) => pos.i === cell.i); + return position ? { ...cell, ...position } : cell; + }); +} + +// todo - extract logic for all types of cells +// x and y are set to the max possible number to ensure end of list +export const registerVideoCellAtom = atom( + null, + (get, set, video: VideoBase) => { + const current = get(multiviewCellsAtom); + + const newVideoCell: VideoCell = { + i: `video_${video.id}`, + type: "video", + video: video, + x: Number.MAX_SAFE_INTEGER, + y: Number.MAX_SAFE_INTEGER, + w: 1, + h: 1, + }; + + const updatedCells = [...current.cells, newVideoCell]; + const finalCells = applyCalculatedPositions(updatedCells); + + set(multiviewCellsAtom, { cells: finalCells }); + }, +); + +export const removeVideoCellAtom = atom(null, (get, set, videoId: string) => { + const currentCells = get(multiviewCellsAtom); + const cellsPostRemoval = currentCells.cells.filter( + (cell) => cell.i !== `video_${videoId}`, + ); + + const finalCells = applyCalculatedPositions(cellsPostRemoval); + set(multiviewCellsAtom, { cells: finalCells }); +}); + +export const updateCellStateAtom = atom( + null, + ( + get, + set, + { cellId, updates }: { cellId: string; updates: Partial }, + ) => { + const curr = get(readMultiviewCellsAtom); + const cellExists = curr.cells.some((cell) => cell.i === cellId); + + if (!cellExists) { + console.warn(`Cell with id ${cellId} not found`); + return; + } + + set(multiviewCellsAtom, { + cells: curr.cells.map((cell) => { + if (cell.i !== cellId) return cell; + // Only allow updates that are valid for the specific cell type + if ( + cell.type === "video" && + (!updates.type || updates.type === "video") + ) { + return { ...cell, ...(updates as Partial) }; + } + if ( + cell.type === "chat" && + (!updates.type || updates.type === "chat") + ) { + return { ...cell, ...(updates as Partial) }; + } + if ( + cell.type === "placeholder" && + (!updates.type || updates.type === "placeholder") + ) { + return { ...cell, ...(updates as Partial) }; + } + return cell; + }), + }); + }, +); + +export const updateCellPositionAtom = atom( + null, + ( + get, + set, + cellId: string, + updates: Partial>, + ) => { + const curr = get(readMultiviewCellsAtom); + const targetCellIndex = curr.cells.findIndex((cell) => cell.i === cellId); + + if (targetCellIndex === -1) { + console.warn(`Cell with id ${cellId} not found`); + return; + } + + const targetCell = curr.cells[targetCellIndex]; + + // Check if any values actually changed + const hasChanges = Object.keys(updates).some((key) => { + const updateKey = key as keyof typeof updates; + return ( + updates[updateKey] !== undefined && + targetCell[updateKey] !== updates[updateKey] + ); + }); + + if (!hasChanges) { + console.log(`Cell with id ${cellId} has no changes to apply`); + return; + } + + // Create new array with only the changed cell replaced + const newCells = [...curr.cells]; + newCells[targetCellIndex] = { + ...targetCell, + ...updates, + }; + + set(multiviewCellsAtom, { + cells: newCells, + }); + }, +); + +export const updateCellStatusAtom = atom( + null, + (_, set, { cellId, status }: { cellId: string; status: ChatCellStatus }) => { + set(updateCellStateAtom, { cellId, updates: { status } }); + }, +); + +const mutateCellTypesAtom = atom( + null, + (get, set, { cellId, newCell }: { cellId: string; newCell: Cell }) => { + // find in the array of cells the cell with the given cell id + const curr = get(readMultiviewCellsAtom); + const cell = curr.cells.find((cell) => cell.i === cellId); + if (!cell) { + console.warn(`Cell with id ${cellId} not found`); + return; + } + + set(multiviewCellsAtom, { + cells: curr.cells.map((cell) => + cell.i === cellId + ? { + ...newCell, + x: cell.x ?? 0, + y: cell.y ?? 0, + h: cell.h ?? 1, + w: cell.w ?? 1, + } + : cell, + ), + }); + }, +); + +export const mutateVideoToPlaceholderAtom = atom( + null, + (_, set, videoId: string) => { + const id = cleanMultiviewCellId(videoId); + const newPlaceholderCell: PlaceholderCell = { + i: `placeholder_${id}`, + type: "placeholder", + x: 0, + y: 0, + w: 0, + h: 0, + }; + set(mutateCellTypesAtom, { + cellId: `video_${id}`, + newCell: newPlaceholderCell, + }); + }, +); + +export const mutatePlaceholderToOtherCellAtom = atom( + null, + (_, set, { cellId, newCell }: { cellId: string; newCell: Cell }) => { + const id = cleanMultiviewCellId(cellId); + set(mutateCellTypesAtom, { + cellId: `placeholder_${id}`, + newCell: { + ...newCell, + i: `${newCell.type}_${id}`, // Ensure the new cell has the correct prefix + }, + }); + }, +); + +export function cleanMultiviewCellId(id: string): string { + // remove placeholder, video and chat prefixes + if (id.startsWith("placeholder_")) return id.replace("placeholder_", ""); + if (id.startsWith("chat_")) return id.replace("chat_", ""); + return id.startsWith("video_") ? id.replace("video_", "") : id; +} + +// function to manage cell layout diff --git a/packages/react/src/types/multiview.d.ts b/packages/react/src/types/multiview.d.ts new file mode 100644 index 000000000..303fa80f1 --- /dev/null +++ b/packages/react/src/types/multiview.d.ts @@ -0,0 +1,31 @@ +import GridLayout from "react-grid-layout"; + +type CellType = "video" | "chat" | "placeholder"; +type ChatCellStatus = "active" | "inactive"; + +interface BaseCell extends GridLayout.Layout { + type: CellType; + orderForAutoLayout?: number; +} + +interface VideoCell extends BaseCell { + type: "video"; + video: VideoBase; +} + +interface ChatCell extends BaseCell { + type: "chat"; + status: ChatCellStatus; + channelId: string; +} + +interface PlaceholderCell extends BaseCell { + type: "placeholder"; + status?: undefined; +} + +type Cell = VideoCell | ChatCell | PlaceholderCell; + +interface MultiviewCells { + cells: Cell[]; +}