diff --git a/src/components/Playlist/Header.tsx b/src/components/Playlist/Header.tsx index 19a4848..02dc640 100644 --- a/src/components/Playlist/Header.tsx +++ b/src/components/Playlist/Header.tsx @@ -35,6 +35,7 @@ const PlaylistHeader = ({ playlist, handleOpen, handleDeleteOpen }: any) => { Settings + Multistream handleOpen()}>Edit handleDeleteOpen()} color="red"> Delete diff --git a/src/components/Video/MultistreamTimeline.module.css b/src/components/Video/MultistreamTimeline.module.css new file mode 100644 index 0000000..5c04e77 --- /dev/null +++ b/src/components/Video/MultistreamTimeline.module.css @@ -0,0 +1,73 @@ +.timelineGrid { + position: relative; + flex-shrink: 0; + overflow: auto; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 0.25rem; + margin: 0 1rem 1rem 1rem; +} + +.timelineStreamerColumn { + display: flex; + gap: 0.25rem; +} + +.timelineBar { + position: relative; + height: 0.75rem; + border-radius: 0.25rem; + overflow: hidden; +} + +.timelineBar:before { + content: ""; + background: var(--mantine-color-violet-9); + opacity: 0.25; + position: absolute; + inset: 0; +} + +.timelineBarActive { + pointer-events: none; + background-color: var(--mantine-color-violet-9); + position: absolute; + top: 0; + bottom: 0; + left: var(--bar-start); + width: var(--bar-length); +} + +.gridInput { + width: 4.5rem; +} + +.offsetInput { + width: 4.5rem; +} + +.playheadContainer { + position: absolute; + grid-column: 2 / 3; + grid-row: 1; + width: 100%; + height: 100%; +} + +.playhead { + position: absolute; + top: 0; + bottom: 0; + left: var(--playhead-position); + transform: translateX(-50%); + width: 0.125rem; + background-color: var(--mantine-color-grape-5); + opacity: 0.75; + pointer-events: none; +} + +.playheadPreview { + background-color: var(--mantine-color-pink-5); + opacity: 0.75; +} \ No newline at end of file diff --git a/src/components/Video/MultistreamTimeline.tsx b/src/components/Video/MultistreamTimeline.tsx new file mode 100644 index 0000000..373d80d --- /dev/null +++ b/src/components/Video/MultistreamTimeline.tsx @@ -0,0 +1,227 @@ +import { ActionIcon, Center, Group, Image, NumberInput, Stack, Tooltip } from "@mantine/core"; +import classes from "./MultistreamTimeline.module.css" +import { IconPlayerPauseFilled, IconPlayerPlayFilled, IconRewindBackward5, IconRewindBackward60, IconRewindForward5, IconRewindForward60 } from "@tabler/icons-react"; +import React, { Fragment, useRef, useState } from "react"; +import dayjs from "dayjs"; +import getConfig from "next/config"; + +export type MultistreamTimelineProps = { + seek: (time: number) => void; + pause: () => void; + play: () => void; + vodPlaybackOffsets: Record; + globalTime: number; + startDateMs: number | null; + endDateMs: number | null; + playStartAtDate: number; + playing: boolean; + setVodOffset: (vodId: string, offset: number) => void; + playingVodForStreamer: Record; + streamers: Record; + gridWidth: number; + gridHeight: number; + setGridWidth: (width: number) => void; + setGridHeight: (height: number) => void; + onStreamerDragStart: () => void; +} + +type Vod = { + id: string + duration: number + streamed_at: string + created_at: string + type: 'live' | 'archive' +} + +export const MultistreamTimeline = ({ vodPlaybackOffsets, globalTime, startDateMs, endDateMs, playStartAtDate, seek, pause, play, playing, playingVodForStreamer, streamers, setVodOffset, onStreamerDragStart, gridWidth, gridHeight, setGridWidth, setGridHeight }: MultistreamTimelineProps) => { + const { publicRuntimeConfig } = getConfig(); + const [timelineTooltipText, setTimelineTooltipText] = useState(""); + const [hoverPlayheadDate, setHoverPlayheadDate] = useState(null); + const timeAtMousePosition = (timelineBar: HTMLDivElement | null, event: React.MouseEvent) => { + if (!timelineBar) return null; + const rect = timelineBar.getBoundingClientRect(); + const x = event.clientX - rect.left; + const percentage = x / rect.width; + const globalTime = startDateMs! + percentage * timelineDurationMs; + return globalTime; + } + + const streamerImageRefs = useRef>({}); + + const onTimelineClick = (timelineBar: HTMLDivElement | null, event: React.MouseEvent) => { + const newGlobalTime = timeAtMousePosition(timelineBar, event); + if (newGlobalTime == null) { + return; + } + seek(newGlobalTime); + } + + const hideHoverPlayhead = () => { + setHoverPlayheadDate(null); + } + const updateHoverPlayhead = (timelineBar: HTMLDivElement | null, event: React.PointerEvent) => { + setHoverPlayheadDate(timeAtMousePosition(timelineBar, event)); + } + const timelineDurationMs: number = startDateMs != null && endDateMs != null ? endDateMs - startDateMs : 0; + + const getCurrentTime = () => { + return (playing ? (Date.now() - playStartAtDate) : 0) + globalTime + } + + const updateTimelineTooltip = (timelineBar: HTMLDivElement | null, event: React.PointerEvent) => { + const timeUnderPointer = timeAtMousePosition(timelineBar, event); + setTimelineTooltipText(timeUnderPointer != null ? dayjs(timeUnderPointer).format("YYYY/MM/DD HH:mm:ss") : ""); + } + + const onStreamerNameDragStart = (event: React.DragEvent, streamerId: string) => { + event.dataTransfer.setData("streamerid", streamerId); + event.dataTransfer.effectAllowed = 'move'; + if (streamerImageRefs.current?.[streamerId]) { + event.dataTransfer.setDragImage(streamerImageRefs.current[streamerId], 0, 0); + } + onStreamerDragStart(); + } + + const timelineEnd = dayjs(endDateMs).format("YYYY/MM/DD HH:mm:ss") + + return + + + seek(getCurrentTime() - 60000)} + > + + + + seek(getCurrentTime() - 5000)} + > + + + + { playing ? pause() : play() }} + size="md" + variant="subtle" + color="violet" + aria-label={playing ? "Pause" : "Play"} + > + {playing ? : } + + + + + seek(getCurrentTime() + 5000)} + > + + + + seek(getCurrentTime() + 60000)} + > + + + setGridWidth(+value)} size="xs" step={1} min={1} /> + setGridHeight(+value)} size="xs" step={1} min={1} /> + + +
+ +
+ {dayjs(getCurrentTime()).format("YYYY/MM/DD HH:mm:ss")} / {timelineEnd} +
+
+
+ + { + startDateMs != null && endDateMs != null &&
+ {Object.keys(streamers).map((streamerId) => { + const streamer = streamers[streamerId] + + const timelineBar =
+ {streamer.vods.map(vod =>
)} +
+ + const playingVod = playingVodForStreamer[streamerId]; + + return ( + +
+ +
onStreamerNameDragStart(e, streamerId)} draggable="true"> + + { streamerImageRefs.current[streamerId] = img }} src={`${publicRuntimeConfig.CDN_URL}${streamer.imagePath}`} alt={streamer.name} w={'1.5em'} h={'1.5em'} radius={'1.5em'} /> + {streamer.name} + +
+ { + if (!playingVod) return; + const valAsNumber = Math.trunc(+value * 1000); + if (isNaN(valAsNumber)) return; + setVodOffset(playingVod.id, valAsNumber); + }} + /> +
+
+
{timelineBar}
+
+ ) + })} + + { + (() => { + let timelineBarRef: HTMLDivElement | null = null; + + return +
timelineBarRef = el} onClick={(event) => onTimelineClick(timelineBarRef, event)} onPointerMove={(event) => { updateTimelineTooltip(timelineBarRef, event); updateHoverPlayhead(timelineBarRef, event) }} onPointerLeave={hideHoverPlayhead}> +
+ {hoverPlayheadDate != null &&
} +
+
+ })() + } +
+ } +
+} + +function getVodStartDate(vod: Vod): string { + if (!vod) { + return ''; + } + if (vod.type === 'live') { + return vod.created_at; + } + return vod.streamed_at; +} \ No newline at end of file diff --git a/src/components/Video/SyncedVideoPlayer.module.css b/src/components/Video/SyncedVideoPlayer.module.css new file mode 100644 index 0000000..35628a1 --- /dev/null +++ b/src/components/Video/SyncedVideoPlayer.module.css @@ -0,0 +1,11 @@ +.mediaPlayer { + height: 100%; + + video { + height: 100%; + } +} + +.ganymedePoster { + object-fit: contain; +} \ No newline at end of file diff --git a/src/components/Video/SyncedVideoPlayer.tsx b/src/components/Video/SyncedVideoPlayer.tsx new file mode 100644 index 0000000..2d3578a --- /dev/null +++ b/src/components/Video/SyncedVideoPlayer.tsx @@ -0,0 +1,75 @@ +import { MediaPlayer, MediaPlayerInstance, MediaProvider, MediaProviderInstance, Poster, Track } from "@vidstack/react"; +import { defaultLayoutIcons, DefaultVideoLayout } from '@vidstack/react/player/layouts/default'; +import getConfig from "next/config"; +import { useEffect, useRef, useState } from "react"; +import '@vidstack/react/player/styles/default/theme.css'; +import '@vidstack/react/player/styles/default/layouts/video.css'; +import classes from "./SyncedVideoPlayer.module.css" + +export type SyncedVideoPlayerProps = { + src: string; + vodId: string; + title: string; + poster: string; + time: number; + playing: boolean; + muted: boolean; +} + +const SyncedVideoPlayer = ({ src, vodId, title, poster, time, playing, muted }: SyncedVideoPlayerProps) => { + const { publicRuntimeConfig } = getConfig(); + const player = useRef(null) + const mediaProvider = useRef(null) + const [canPlay, setCanPlay] = useState(false) + + useEffect(() => { + const currentPlayer = player.current + if (!currentPlayer || !canPlay) return; + (async () => { + if (playing) { + currentPlayer.currentTime = time; + await (new Promise(resolve => setTimeout(resolve, 1))); + await currentPlayer.play(); + } else { + await (new Promise(resolve => setTimeout(resolve, 1))); + await currentPlayer.pause(); + } + })(); + }, [playing, canPlay]) + + useEffect(() => { + if (!player.current) return; + player.current.muted = muted; + }, [muted]) + + useEffect(() => { + if (!player.current || Math.abs(player.current.currentTime - time) < 0.2) return; + player.current.currentTime = time; + }, [time]) + + return ( + setCanPlay(true)} + playsInline + muted={muted} + > + + + + + + + + ) +}; + +export default SyncedVideoPlayer; \ No newline at end of file diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index f739b68..b865d49 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -7,6 +7,7 @@ import type { AppContext, AppProps } from "next/app"; import App from "next/app"; import { Container, + Drawer, MantineProvider, createTheme, em, @@ -74,6 +75,18 @@ export default function MyApp({ Component, pageProps }: AppProps) { }, }), }), + Drawer: Drawer.extend({ + vars: (_, { size }) => { + if (size === 'auto') { + return { + root: { + '--drawer-size': 'auto' + } + } + } + return { root: {} } + } + }), }, colors: { dark: [ diff --git a/src/pages/playlists/multistream/[playlistId].tsx b/src/pages/playlists/multistream/[playlistId].tsx new file mode 100644 index 0000000..0261eb2 --- /dev/null +++ b/src/pages/playlists/multistream/[playlistId].tsx @@ -0,0 +1,509 @@ +import getConfig from "next/config"; +import useUserStore from "../../../store/user"; +import Head from "next/head"; +import VodLoginRequired from "../../../components/Video/LoginRequred"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useApi } from "../../../hooks/useApi"; +import GanymedeLoader from "../../../components/Utils/GanymedeLoader"; +import React, { ReactNode, useEffect, useState } from "react"; +import SyncedVideoPlayer from "../../../components/Video/SyncedVideoPlayer"; +import { escapeURL } from "../../../util/util"; +import classes from "./playlistMultistream.module.css" +import { ActionIcon, Drawer, Text } from "@mantine/core"; +import { IconChevronUp } from "@tabler/icons-react"; +import { useDisclosure, useInterval } from "@mantine/hooks"; +import { MultistreamTimeline } from "../../../components/Video/MultistreamTimeline"; + +export async function getServerSideProps(context: any) { + const { playlistId } = context.query; + return { + props: { + playlistId, + }, + }; +} + +const PlaylistMultistream = (props: { playlistId: string }) => { + const { publicRuntimeConfig } = getConfig(); + const user = useUserStore((state) => state); + const videoGrid = React.createRef(); + const [streamerViewState, setStreamerViewState] = useState>({}); + const [vodPlaybackOffsets, setVodPlaybackOffsets] = useState>({}); + + const checkLoginRequired = () => { + if ( + publicRuntimeConfig.REQUIRE_LOGIN && + publicRuntimeConfig.REQUIRE_LOGIN == "true" && + !user.isLoggedIn + ) { + return true; + } + return false; + }; + + const { isLoading, error, data } = useQuery({ + queryKey: ["playlist", props.playlistId], + queryFn: () => + useApi( + { + method: "GET", + url: `/api/v1/playlist/${props.playlistId}?with_multistream_info=true`, + }, + false + ).then((res) => res?.data), + }); + + const updateVodOffset = useMutation({ + mutationKey: ["save-delay"], + mutationFn: async ({ playlistId, vodId, delayMs }: { playlistId: string, vodId: string, delayMs: number }) => { + await useApi( + { + method: "PUT", + url: `/api/v1/playlist/${playlistId}/multistream/delay`, + withCredentials: true, + data: { + vod_id: vodId, + delay_ms: delayMs, + } + }, + false + ).catch((err) => { + console.log("Error saving delay", err); + }); + }, + }) + + const [playing, setPlaying] = useState(false); + const [playStartAtDate, setPlayStartAtDate] = useState(0); + const [globalTime, setGlobalTime] = useState(0); + const [globalTimeUpdate, setGlobalTimeUpdate] = useState(0); + const [startDateMs, setStartDateMs] = useState(null); + const [endDateMs, setEndDateMs] = useState(null); + const [streamers, setStreamers] = useState>({}); + const [gridWidth, setGridWidth] = useState(2); + const [gridHeight, setGridHeight] = useState(1); + const [dragOverTile, setDragOverTile] = useState<[number, number] | null>(null); + const [dropEnabled, setDropEnabled] = useState(false); + const [_, setEnterEvents] = useState(0); + const [resizeMode, setResizeMode] = useState(false); + const [resizeOverlayParams, setResizeOverlayParams] = useState<{ tileX: number; tileY: number; tileWidth: number; tileHeight: number, streamerId: string } | null>(null); + + const [opened, { open, close }] = useDisclosure(true); + + const videoCheckInterval = useInterval(() => { + setGlobalTimeUpdate((playing ? (Date.now() - playStartAtDate) : 0) + globalTime) + }, 1000) + + // Update start and end of the timeline + useEffect(() => { + if (!data) { + return; + } + let _startDateMs: number | null = null; + let _endDateMs: number | null = null; + let _streamers: Record = {}; + for (let i = 0; i < data.edges.vods.length; i++) { + const vod = data.edges.vods[i]; + const vodStartDateMs = +new Date(getVodStartDate(vod)) + if (_startDateMs == null || vodStartDateMs < _startDateMs) { + _startDateMs = vodStartDateMs; + } + const vodEndDateMs = vodStartDateMs + vod.duration * 1000; + if (_endDateMs == null || _endDateMs < vodEndDateMs) { + _endDateMs = vodEndDateMs; + } + + if (!_streamers[vod.edges.channel.id]) { + _streamers[vod.edges.channel.id] = { + name: vod.edges.channel.name, + vods: [], + imagePath: vod.edges.channel.image_path, + } + } + + _streamers[vod.edges.channel.id].vods.push(vod) + } + + // COmpute default view + const streamersCount = Object.keys(_streamers).length; + let rows = 1; + if (streamersCount > 3) { + rows = 2; + } + let columns = Math.ceil(streamersCount / rows); + setGridWidth(columns); + setGridHeight(rows); + let i = 0; + let defaultViewState: Record = {} + for (let streamerId in _streamers) { + defaultViewState[streamerId] = { + tileX: i % columns, + tileY: Math.floor(i / columns), + tileWidth: 1, + tileHeight: 1, + } + i++; + } + setStreamerViewState(defaultViewState); + + setStartDateMs(_startDateMs) + setEndDateMs(_endDateMs) + setStreamers(_streamers) + + setVodPlaybackOffsets((prevState) => { + const newState = { ...prevState }; + if (data.edges.multistream_info) { + for (let i = 0; i < data.edges.multistream_info.length; i++) { + const multistreamInfo = data.edges.multistream_info[i]; + newState[multistreamInfo.edges.vod.id] = multistreamInfo.delay_ms; + } + } + return newState; + }) + }, [data]) + + useEffect(() => { + videoCheckInterval.stop() + if (!playing) { + return + } + videoCheckInterval.start(); + return videoCheckInterval.stop; + }, [playing]); + + if (error) return
failed to load
; + if (isLoading) return ; + if (data.edges.vods.length === 0) return
Empty playlist, unable to start multistream.
; + + const seek = (newGlobalTime: number) => { + setPlayStartAtDate(Date.now()); + setGlobalTime(newGlobalTime); + setGlobalTimeUpdate(newGlobalTime); + } + + if (startDateMs != null && globalTime < startDateMs) { + seek(startDateMs); + } + + const onUserPlay = () => { + setPlayStartAtDate(Date.now()); + setPlaying(true); + } + + const onUserPause = (pausedAtGlobalTime: number) => { + setPlaying(false); + setGlobalTime(pausedAtGlobalTime) + setGlobalTimeUpdate(pausedAtGlobalTime); + } + + const startMovingTile = (streamerId: string) => { + if (!streamerViewState[streamerId]) return + setResizeMode('move'); + setResizeOverlayParams({ ...streamerViewState[streamerId], streamerId }); + } + + const startResizingTile = (streamerId: string) => { + if (!streamerViewState[streamerId]) return + setResizeMode('resize'); + setResizeOverlayParams({ ...streamerViewState[streamerId], streamerId }); + } + + const checkResize = (event: React.PointerEvent) => { + if (!resizeOverlayParams || !resizeMode) return; + const gridRect = videoGrid.current?.getBoundingClientRect(); + if (!gridRect) return; + const tileXUnderMouse = Math.floor((event.clientX - gridRect.left) / (gridRect.width / gridWidth)); + const tileYUnderMouse = Math.floor((event.clientY - gridRect.top) / (gridRect.height / gridHeight)); + switch (resizeMode) { + case 'move': { + setResizeOverlayParams((prevState) => { + if (!prevState) return null; + const newState = { ...prevState }; + newState.tileX = Math.min(tileXUnderMouse, gridWidth - 1); + newState.tileY = Math.min(tileYUnderMouse, gridHeight - 1); + return newState; + }) + break + } + case 'resize': { + setResizeOverlayParams((prevState) => { + if (!prevState) return null; + const newState = { ...prevState }; + newState.tileWidth = Math.min(Math.max(1, tileXUnderMouse - prevState.tileX + 1), gridWidth - prevState.tileX); + newState.tileHeight = Math.min(Math.max(1, tileYUnderMouse - prevState.tileY + 1), gridHeight - prevState.tileY); + return newState; + }) + break + } + } + } + + const endTileResize = () => { + if (resizeMode) { + setResizeMode(false); + } else { + return + } + if (!resizeOverlayParams) return; + setStreamerViewState((prevState) => { + const newState = { ...prevState }; + newState[resizeOverlayParams.streamerId] = { + tileX: resizeOverlayParams.tileX, + tileY: resizeOverlayParams.tileY, + tileWidth: resizeOverlayParams.tileWidth, + tileHeight: resizeOverlayParams.tileHeight, + }; + return newState; + }) + } + + const playingVodForStreamer: Record = {}; + + const playerTiles = Object.keys(streamerViewState).map((streamerId) => { + const streamer = streamers[streamerId]; + const viewState = streamerViewState[streamerId]; + if (!viewState || !streamer) { + return null; + } + const playingVod = getVodAtTime(streamer.vods, vodPlaybackOffsets, globalTimeUpdate); + playingVodForStreamer[streamerId] = playingVod; + if (!playingVod) { + return startMovingTile(streamerId)} + startResizing={() => startResizingTile(streamerId)} + remove={() => { + setStreamerViewState((prevState) => { + const newState = { ...prevState }; + delete newState[streamerId]; + return newState; + }) + }} + > + + {streamer.name}
+ OFFLINE +
+
+ } + const playbackOffset = (vodPlaybackOffsets[playingVod.id] || 0) / 1000; + const currentGlobalTime = (playing ? (Date.now() - playStartAtDate) : 0) + globalTime + const vodTime = (currentGlobalTime - (+new Date(getVodStartDate(playingVod)))) / 1000 + playbackOffset; + return ( + startMovingTile(streamerId)} + startResizing={() => startResizingTile(streamerId)} + remove={() => { + setStreamerViewState((prevState) => { + const newState = { ...prevState }; + delete newState[streamerId]; + return newState; + }) + }} + > + + + ) + }) + + const checkDropData = (event: React.DragEvent) => { + if (!event.dataTransfer.types.includes('streamerid')) return + setDropEnabled(true); + setEnterEvents((prevState) => { + const newEnterEvents = Math.min(2, prevState + 1) + return newEnterEvents + }) + } + const leaveDropZone = () => { + setEnterEvents((prevState) => { + const newEnterEvents = Math.max(0, prevState - 1) + if (newEnterEvents == 0) { + setDropEnabled(false) + setDragOverTile(null) + } + return newEnterEvents + }) + } + + const dragOverTileHandler = (x: number, y: number, event: React.DragEvent, immediate: boolean) => { + if (!event.dataTransfer.types.includes('streamerid')) return + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + if (immediate) { + setDragOverTile([x, y]) + } else { + setTimeout(() => { + setDragOverTile([x, y]) + }) + } + } + + const dropOverTileHander = (x: number, y: number, event: React.DragEvent) => { + event.preventDefault(); + const streamerId = event.dataTransfer.getData('streamerid'); + setStreamerViewState((prevState) => { + const newState = { ...prevState }; + newState[streamerId] = { tileX: x, tileY: y, tileWidth: 1, tileHeight: 1 }; + return newState; + }) + setEnterEvents(0) + setDropEnabled(false) + setDragOverTile(null) + } + + const dropTiles: React.JSX.Element[] = [] + for (let x = 0; x < gridWidth; x++) { + for (let y = 0; y < gridHeight; y++) { + dropTiles.push(
{ dragOverTileHandler(x, y, event, false) }} + onDragOver={(event) => { dragOverTileHandler(x, y, event, true) }} + onDrop={(event) => { dropOverTileHander(x, y, event) }} + >
) + } + } + + return ( +
+ + {data.name} - Ganymede Multistream + + {checkLoginRequired() && || +
+
{ leaveDropZone(); }} + onDragEnter={(event) => { checkDropData(event); }} + onPointerUp={() => { endTileResize() }} + onPointerMove={(event) => { if (resizeMode) checkResize(event) }} + > + {playerTiles} + {dropTiles} + {resizeMode &&
} +
+
+ + + +
+ + { onUserPause(globalTime + Date.now() - playStartAtDate) }} + endDateMs={endDateMs} + startDateMs={startDateMs} + globalTime={globalTime} + playing={playing} + playingVodForStreamer={playingVodForStreamer} + streamers={streamers} + playStartAtDate={playStartAtDate} + seek={seek} + setVodOffset={(vodId, offset) => { + setVodPlaybackOffsets((prevState) => { + const newState = { ...prevState }; + newState[vodId] = offset; + return newState; + }) + updateVodOffset.mutate({ + playlistId: props.playlistId, + vodId: vodId, + delayMs: offset, + }) + }} + vodPlaybackOffsets={vodPlaybackOffsets} + onStreamerDragStart={() => { close() }} + gridWidth={gridWidth} + gridHeight={gridHeight} + setGridWidth={(width) => { setGridWidth(width) }} + setGridHeight={(height) => { setGridHeight(height) }} + /> + +
+ } +
+ ) +} + +type Vod = { + id: string + web_thumbnail_path: string + video_path: string + duration: number + streamed_at: string + created_at: string + type: 'live' | 'archive' + title: string +} + +function getVodStartDate(vod: Vod): string { + if (!vod) { + return ''; + } + if (vod.type === 'live') { + return vod.created_at; + } + return vod.streamed_at; +} + +function getVodAtTime(vods: Vod[], vodPlaybackOffsets: Record, time: number): Vod | null { + for (let i = 0; i < vods.length; i++) { + const vod = vods[i]; + const playbackOffset = (vodPlaybackOffsets[vod.id] || 0) / 1000 + const offsettedTime = time + playbackOffset; + const vodStartDateMs = +new Date(getVodStartDate(vod)) + const vodEndDateMs = vodStartDateMs + vod.duration * 1000; + if (vodStartDateMs <= offsettedTime && offsettedTime <= vodEndDateMs) { + return vod; + } + } + return null; +} + +type ResizableTileProps = { + className: string; + style?: React.CSSProperties; + startMoving: () => void; + startResizing: () => void; + remove: () => void; + children: ReactNode +} +function ResizableTile(props: ResizableTileProps) { + return
+ {props.children} +
props.startMoving()}>
+
props.remove()}>
+
props.startResizing()}>
+
+} + +export default PlaylistMultistream; \ No newline at end of file diff --git a/src/pages/playlists/multistream/playlistMultistream.module.css b/src/pages/playlists/multistream/playlistMultistream.module.css new file mode 100644 index 0000000..7652cd1 --- /dev/null +++ b/src/pages/playlists/multistream/playlistMultistream.module.css @@ -0,0 +1,129 @@ +.pageWrapper { + height: calc(100vh - 60px); + display: flex; +} + +.videosGrid { + flex: 1; + display: grid; + width: 100%; + height: 100%; + position: relative; + grid-template-columns: repeat(var(--grid-columns-count), minmax(0, 1fr)); + grid-template-rows: repeat(var(--grid-rows-count), minmax(0, 1fr)); + + &.dropEnabled .dropTile { + pointer-events: auto; + opacity: 0.25; + } + + &.dropEnabled > :not(.dropTile) { + pointer-events: none; + } + + &.resizeMode > * { + pointer-events: none; + } +} + +.resizeOverlay { + position: absolute; + left: calc(100% * var(--tile-x) / var(--grid-columns-count)); + top: calc(100% * var(--tile-y) / var(--grid-rows-count)); + width: calc(100% * var(--tile-width) / var(--grid-columns-count)); + height: calc(100% * var(--tile-height) / var(--grid-rows-count)); + opacity: 0.2; + background: var(--mantine-color-violet-5); + pointer-events: none; + transition: all 0.2s; + z-index: 150; +} + +.resizableTile { + position: relative; + + & > .topLeftHandle { + position: absolute; + top: 0; + left: 0; + width: 1rem; + height: 1rem; + user-select: none; + touch-action: none; + cursor: move; + z-index: 150; + } + + & > .bottomRightHandle { + position: absolute; + bottom: 0; + right: 0; + width: 1rem; + height: 1rem; + user-select: none; + touch-action: none; + cursor: nwse-resize; + z-index: 150; + } + + & > .topRightHandle { + position: absolute; + top: 0; + right: 0; + width: 1rem; + height: 1rem; + user-select: none; + touch-action: none; + cursor: not-allowed; + z-index: 150; + } +} + +.playerTile { + grid-column: var(--tile-x); + grid-row: var(--tile-y); +} + +.streamerOffline { + display: flex; + justify-content: center; + align-items: center; + text-align: center; + border: 0.125rem solid var(--mantine-color-dark-5); +} + +.dropTile { + opacity: 0; + background: var(--mantine-color-dark-7); + border: 0.125rem solid var(--mantine-color-dark-5); + grid-column: var(--tile-x); + grid-row: var(--tile-y); + pointer-events: none; + transition: all 0.2s; + z-index: 150; +} + +.dropTileHovered { + opacity: 0.5; + background: var(--mantine-color-violet-5); + border: 0.125rem solid var(--mantine-color-violet-7); +} + +.timelineOpenButtonContainer { + display: flex; + justify-content: center; + position: fixed; + bottom: 0; + pointer-events: none; + z-index: 150; + width: 100vw; + + &>* { + pointer-events: auto; + } +} + +.timelineOpenButton { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} \ No newline at end of file