From f9d84c9694917de1888921b041cdd050280390e2 Mon Sep 17 00:00:00 2001 From: Entrivax <1197915+Entrivax@users.noreply.github.com> Date: Mon, 8 Jan 2024 22:04:11 +0100 Subject: [PATCH 1/9] create synced player and a multistream page for playlists --- .../Video/SyncedVideoPlayer.module.css | 7 + src/components/Video/SyncedVideoPlayer.tsx | 119 +++++++++ .../playlists/multistream/[playlistId].tsx | 252 ++++++++++++++++++ .../playlistMultistream.module.css | 72 +++++ 4 files changed, 450 insertions(+) create mode 100644 src/components/Video/SyncedVideoPlayer.module.css create mode 100644 src/components/Video/SyncedVideoPlayer.tsx create mode 100644 src/pages/playlists/multistream/[playlistId].tsx create mode 100644 src/pages/playlists/multistream/playlistMultistream.module.css diff --git a/src/components/Video/SyncedVideoPlayer.module.css b/src/components/Video/SyncedVideoPlayer.module.css new file mode 100644 index 0000000..d186873 --- /dev/null +++ b/src/components/Video/SyncedVideoPlayer.module.css @@ -0,0 +1,7 @@ +.mediaPlayer { + height: 100%; + + video { + height: 100%; + } +} \ 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..78197c0 --- /dev/null +++ b/src/components/Video/SyncedVideoPlayer.tsx @@ -0,0 +1,119 @@ +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" +import { useInterval } from "@mantine/hooks"; + +export type SyncedVideoPlayerProps = { + src: string; + vodId: string; + title: string; + poster: string; + time: number; + playing: boolean; + muted: boolean; + onUserSeek: (time: number) => void; + onUserPlay: () => void; + onUserPause: (pausedAtTime: number) => void; + onTimeUpdate: (currentTime: number) => void; +} + +const SyncedVideoPlayer = ({ src, vodId, title, poster, time, playing, muted, onUserSeek, onUserPlay, onUserPause, onTimeUpdate }: SyncedVideoPlayerProps) => { + const { publicRuntimeConfig } = getConfig(); + const player = useRef(null) + const mediaProvider = useRef(null) + const [canPlay, setCanPlay] = useState(false) + const [justSynchonized, setJustSynchronized] = useState(false) + + const onPlay = () => { + if (!player.current || playing) return; + console.log(`vodId: ${vodId} playing`) + onUserPlay() + } + + const onPause = () => { + if (!player.current || !playing) return; + console.log(`vodId: ${vodId} pausing`) + onUserPause(player.current.currentTime) + } + + useEffect(() => { + const currentPlayer = player.current + if (!currentPlayer || !canPlay) return; + console.log(`vodId: ${vodId} playing=${playing}`); + (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]) + + const timeUpdateInterval = useInterval(() => { + // Don't update if it's a rounding frame time problem + if (!player.current || Math.abs(player.current.currentTime - time) < 0.01) return; + onTimeUpdate(player.current.currentTime); + }, 2000) + + useEffect(() => { + if (!player.current) return; + // Synchronize time if in pause or out of sync by half a second + if (Math.abs(player.current.currentTime - time) > 0.5 || !playing) { + player.current.currentTime = time; + setJustSynchronized(true); + } + timeUpdateInterval.start(); + return timeUpdateInterval.stop; + }, [time]) + + const onSeeking = (currentTime: number) => { + if (!player.current) return; + setJustSynchronized((justSynchonized) => { + if (justSynchonized) { + return false; + } + onUserSeek(currentTime); + return false; + }) + } + + return ( + setCanPlay(true)} + playsinline + > + + + + + + + + ) +}; + +export default SyncedVideoPlayer; \ No newline at end of file diff --git a/src/pages/playlists/multistream/[playlistId].tsx b/src/pages/playlists/multistream/[playlistId].tsx new file mode 100644 index 0000000..6aa201e --- /dev/null +++ b/src/pages/playlists/multistream/[playlistId].tsx @@ -0,0 +1,252 @@ +import getConfig from "next/config"; +import useUserStore from "../../../store/user"; +import Head from "next/head"; +import VodLoginRequired from "../../../components/Vod/LoginRequred"; +import { useQuery } from "@tanstack/react-query"; +import { useApi } from "../../../hooks/useApi"; +import GanymedeLoader from "../../../components/Utils/GanymedeLoader"; +import React, { Fragment, useState } from "react"; +import SyncedVideoPlayer from "../../../components/Vod/SyncedVideoPlayer"; +import { escapeURL } from "../../../util/util"; +import classes from "./playlistMultistream.module.css" +import { ActionIcon, Flex, NumberInput, Text } from "@mantine/core"; +import { IconEye, IconEyeOff } from "@tabler/icons-react"; + +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 [streamerViewState, setStreamerViewState] = useState>({}); + const [streamerPlaybackOffsets, setStreamerPlaybackOffsets] = 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}`, + }, + false + ).then((res) => res?.data), + }); + + const [playing, setPlaying] = useState(false); + const [globalTime, setGlobalTime] = useState(0); + const [seeked, setSeeked] = useState(false); + + if (error) return
failed to load
; + if (isLoading) return ; + if (data.edges.vods.length === 0) return
Empty playlist, unable to start multistream.
; + + let startDateMs: number | null = null; + let endDateMs: number | null = null; + const streamers: Record = {} + + for (let i = 0; i < data.edges.vods.length; i++) { + const vod = data.edges.vods[i]; + const vodStartDateMs = +new Date(vod.streamed_at) + 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: [] + } + } + + streamers[vod.edges.channel.id].vods.push(vod) + } + let timelineDurationMs: number = startDateMs != null && endDateMs != null ? endDateMs - startDateMs : 0; + let _seeked = false; + if (seeked) + setSeeked(false); + + const seek = (newGlobalTime: number) => { + _seeked = true; + setSeeked(true); + setGlobalTime(newGlobalTime); + } + + if (startDateMs != null && globalTime < startDateMs) { + seek(startDateMs); + } + + const onUserPlay = () => { + setPlaying(true); + } + + const onUserPause = (pausedAtGlobalTime: number) => { + setPlaying(false); + setGlobalTime(pausedAtGlobalTime) + } + + const onUserSeek = (time: number) => { + seek(time); + } + + const onTimeUpdate = (currentTime: number) => { + setGlobalTime(currentTime); + } + + const onTimelineClick = (timelineBar: HTMLDivElement | null, event: React.MouseEvent) => { + if (!timelineBar) return; + const rect = timelineBar.getBoundingClientRect(); + const x = event.clientX - rect.left; + const percentage = x / rect.width; + const newGlobalTime = startDateMs! + percentage * timelineDurationMs; + seek(newGlobalTime); + } + + const toggleView = (streamerId: string) => { + setStreamerViewState((prevState) => { + const newState = { ...prevState }; + newState[streamerId] = !newState[streamerId]; + return newState; + }) + } + + const playerTiles = Object.keys(streamers).map((streamerId) => { + if (!streamerViewState[streamerId]) { + return null; + } + const streamer = streamers[streamerId]; + const playbackOffset = (streamerPlaybackOffsets[streamerId] || 0) / 1000; + const playingVod = getVodAtTime(streamer.vods, globalTime + playbackOffset); + if (!playingVod) { + return
+ + { streamer.name }
+ OFFLINE +
+
+ } + const vodTime = (globalTime - (+new Date(playingVod?.streamed_at))) / 1000 + playbackOffset; + const toGlobalTime = (time: number) => (time - playbackOffset) * 1000 + (+new Date(playingVod.streamed_at)); + return ( +
+ onUserSeek(toGlobalTime(time))} + onUserPlay={onUserPlay} + onUserPause={(pausedAtTime) => onUserPause(toGlobalTime(pausedAtTime))} + onTimeUpdate={(currentTime) => onTimeUpdate(toGlobalTime(currentTime))} + /> +
+ ) + }) + + return ( +
+ + {data.name} - Ganymede + + { checkLoginRequired() && || +
+
acc + +(player != null), 0) } as React.CSSProperties}> + { playerTiles } +
+ { + startDateMs != null && endDateMs != null &&
+ { Object.keys(streamers).map((streamerId) => { + const streamer = streamers[streamerId] + + let timelineBarRef: HTMLDivElement | null = null; + + const timelineBar =
timelineBarRef = el} onClick={(event) => onTimelineClick(timelineBarRef, event)}> + { streamer.vods.map(vod =>
) } +
+ + return ( + +
+ { streamer.name } + toggleView(streamerId)}> + { streamerViewState[streamerId] ? : } + + { + const valAsNumber = +value * 1000; + if (isNaN(valAsNumber)) return; + setStreamerPlaybackOffsets((prevState) => { + const newState = { ...prevState }; + newState[streamerId] = valAsNumber; + return newState; + }) + }} + /> +
+
{ timelineBar }
+
+ ) + })} +
+ } +
+ } +
+ ) +} + +type Vod = { + id: string + video_path: string + duration: number + streamed_at: string + title: string +} + +function getVodAtTime(vods: Vod[], time: number): Vod | null { + for (let i = 0; i < vods.length; i++) { + const vod = vods[i]; + const vodStartDateMs = +new Date(vod.streamed_at) + const vodEndDateMs = vodStartDateMs + vod.duration * 1000; + if (vodStartDateMs <= time && time <= vodEndDateMs) { + return vod; + } + } + return null; +} + +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..caf2a05 --- /dev/null +++ b/src/pages/playlists/multistream/playlistMultistream.module.css @@ -0,0 +1,72 @@ +.pageWrapper { + height: calc(100vh - 60px); + display: flex; + flex-direction: column; + gap: 1rem; +} + +.videosGrid { + flex: 1; + max-height: 100%; + display: flex; + flex-wrap: wrap; +} + +.playerTile { + --max-per-row: 2; + width: calc(100% / min(var(--players-count), var(--max-per-row))); + max-height: 100%; + + @media (min-aspect-ratio: 1.9) { + --max-per-row: 3; + } + @media (max-aspect-ratio: 0.9) { + --max-per-row: 1; + } +} + +.streamerOffline { + display: flex; + justify-content: center; + align-items: center; + text-align: center; +} + +.timelineGrid { + min-height: 5rem; + max-height: 5rem; + 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 { + background-color: currentColor; + position: relative; + height: 0.75rem; + border-radius: 0.25rem; + overflow: hidden; +} + +.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); +} + +.offsetInput { + width: 5rem; +} \ No newline at end of file From 6424867cc6ef5ff13433b20dc3d5ab1444f64fe4 Mon Sep 17 00:00:00 2001 From: Entrivax <1197915+Entrivax@users.noreply.github.com> Date: Sat, 13 Jan 2024 15:01:13 +0100 Subject: [PATCH 2/9] feat(multistream): save delay per vod and to database --- src/components/Playlist/Header.tsx | 1 + .../playlists/multistream/[playlistId].tsx | 131 +++++++++++++----- .../playlistMultistream.module.css | 2 +- 3 files changed, 96 insertions(+), 38 deletions(-) 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/pages/playlists/multistream/[playlistId].tsx b/src/pages/playlists/multistream/[playlistId].tsx index 6aa201e..2ebfe65 100644 --- a/src/pages/playlists/multistream/[playlistId].tsx +++ b/src/pages/playlists/multistream/[playlistId].tsx @@ -2,10 +2,10 @@ import getConfig from "next/config"; import useUserStore from "../../../store/user"; import Head from "next/head"; import VodLoginRequired from "../../../components/Vod/LoginRequred"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { useApi } from "../../../hooks/useApi"; import GanymedeLoader from "../../../components/Utils/GanymedeLoader"; -import React, { Fragment, useState } from "react"; +import React, { Fragment, useEffect, useState } from "react"; import SyncedVideoPlayer from "../../../components/Vod/SyncedVideoPlayer"; import { escapeURL } from "../../../util/util"; import classes from "./playlistMultistream.module.css" @@ -25,7 +25,7 @@ const PlaylistMultistream = (props: { playlistId: string }) => { const { publicRuntimeConfig } = getConfig(); const user = useUserStore((state) => state); const [streamerViewState, setStreamerViewState] = useState>({}); - const [streamerPlaybackOffsets, setStreamerPlaybackOffsets] = useState>({}); + const [vodPlaybackOffsets, setVodPlaybackOffsets] = useState>({}); const checkLoginRequired = () => { if ( @@ -50,48 +50,91 @@ const PlaylistMultistream = (props: { playlistId: string }) => { ).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}/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 [globalTime, setGlobalTime] = useState(0); const [seeked, setSeeked] = useState(false); - - if (error) return
failed to load
; - if (isLoading) return ; - if (data.edges.vods.length === 0) return
Empty playlist, unable to start multistream.
; - - let startDateMs: number | null = null; - let endDateMs: number | null = null; - const streamers: Record(null); + const [endDateMs, setEndDateMs] = useState(null); + const [streamers, setStreamers] = useState = {} + }>>({}); - for (let i = 0; i < data.edges.vods.length; i++) { - const vod = data.edges.vods[i]; - const vodStartDateMs = +new Date(vod.streamed_at) - if (startDateMs == null || vodStartDateMs < startDateMs) { - startDateMs = vodStartDateMs; - } - const vodEndDateMs = vodStartDateMs + vod.duration * 1000; - if (endDateMs == null || endDateMs < vodEndDateMs) { - endDateMs = vodEndDateMs; + 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(vod.streamed_at) + 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: [] + if (!_streamers[vod.edges.channel.id]) { + _streamers[vod.edges.channel.id] = { + name: vod.edges.channel.name, + vods: [] + } } + + _streamers[vod.edges.channel.id].vods.push(vod) } + 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]) + + if (error) return
failed to load
; + if (isLoading) return ; + if (data.edges.vods.length === 0) return
Empty playlist, unable to start multistream.
; - streamers[vod.edges.channel.id].vods.push(vod) - } let timelineDurationMs: number = startDateMs != null && endDateMs != null ? endDateMs - startDateMs : 0; - let _seeked = false; if (seeked) setSeeked(false); const seek = (newGlobalTime: number) => { - _seeked = true; setSeeked(true); setGlobalTime(newGlobalTime); } @@ -134,13 +177,15 @@ const PlaylistMultistream = (props: { playlistId: string }) => { }) } + const playingVodForStreamer: Record = {}; + const playerTiles = Object.keys(streamers).map((streamerId) => { if (!streamerViewState[streamerId]) { return null; } const streamer = streamers[streamerId]; - const playbackOffset = (streamerPlaybackOffsets[streamerId] || 0) / 1000; - const playingVod = getVodAtTime(streamer.vods, globalTime + playbackOffset); + const playingVod = getVodAtTime(streamer.vods, vodPlaybackOffsets, globalTime); + playingVodForStreamer[streamerId] = playingVod; if (!playingVod) { return
@@ -149,6 +194,7 @@ const PlaylistMultistream = (props: { playlistId: string }) => {
} + const playbackOffset = (vodPlaybackOffsets[playingVod.id] || 0) / 1000; const vodTime = (globalTime - (+new Date(playingVod?.streamed_at))) / 1000 + playbackOffset; const toGlobalTime = (time: number) => (time - playbackOffset) * 1000 + (+new Date(playingVod.streamed_at)); return ( @@ -194,6 +240,8 @@ const PlaylistMultistream = (props: { playlistId: string }) => { } as React.CSSProperties}>) } + const playingVod = playingVodForStreamer[streamerId]; + return (
@@ -204,16 +252,23 @@ const PlaylistMultistream = (props: { playlistId: string }) => { { - const valAsNumber = +value * 1000; + if (!playingVod) return; + const valAsNumber = Math.trunc(+value * 1000); if (isNaN(valAsNumber)) return; - setStreamerPlaybackOffsets((prevState) => { + setVodPlaybackOffsets((prevState) => { const newState = { ...prevState }; - newState[streamerId] = valAsNumber; + newState[playingVod.id] = valAsNumber; return newState; }) + updateVodOffset.mutate({ + playlistId: props.playlistId, + vodId: playingVod.id, + delayMs: valAsNumber, + }) }} />
@@ -237,12 +292,14 @@ type Vod = { title: string } -function getVodAtTime(vods: Vod[], time: number): Vod | null { +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(vod.streamed_at) const vodEndDateMs = vodStartDateMs + vod.duration * 1000; - if (vodStartDateMs <= time && time <= vodEndDateMs) { + if (vodStartDateMs <= offsettedTime && offsettedTime <= vodEndDateMs) { return vod; } } diff --git a/src/pages/playlists/multistream/playlistMultistream.module.css b/src/pages/playlists/multistream/playlistMultistream.module.css index caf2a05..87ef268 100644 --- a/src/pages/playlists/multistream/playlistMultistream.module.css +++ b/src/pages/playlists/multistream/playlistMultistream.module.css @@ -68,5 +68,5 @@ } .offsetInput { - width: 5rem; + width: 5.75rem; } \ No newline at end of file From f26cb83f3dc53524e248c53bd2514157ba9ae7e5 Mon Sep 17 00:00:00 2001 From: Entrivax <1197915+Entrivax@users.noreply.github.com> Date: Sat, 13 Jan 2024 16:36:36 +0100 Subject: [PATCH 3/9] feat(multistream): add playhead --- .../playlists/multistream/[playlistId].tsx | 29 ++++++++++++++---- .../playlistMultistream.module.css | 30 ++++++++++++++++++- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/pages/playlists/multistream/[playlistId].tsx b/src/pages/playlists/multistream/[playlistId].tsx index 2ebfe65..febaa72 100644 --- a/src/pages/playlists/multistream/[playlistId].tsx +++ b/src/pages/playlists/multistream/[playlistId].tsx @@ -160,12 +160,20 @@ const PlaylistMultistream = (props: { playlistId: string }) => { setGlobalTime(currentTime); } - const onTimelineClick = (timelineBar: HTMLDivElement | null, event: React.MouseEvent) => { - if (!timelineBar) return; + 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 newGlobalTime = startDateMs! + percentage * timelineDurationMs; + const globalTime = startDateMs! + percentage * timelineDurationMs; + return globalTime; + } + + const onTimelineClick = (timelineBar: HTMLDivElement | null, event: React.MouseEvent) => { + const newGlobalTime = timeAtMousePosition(timelineBar, event); + if (newGlobalTime == null) { + return; + } seek(newGlobalTime); } @@ -231,9 +239,7 @@ const PlaylistMultistream = (props: { playlistId: string }) => { { Object.keys(streamers).map((streamerId) => { const streamer = streamers[streamerId] - let timelineBarRef: HTMLDivElement | null = null; - - const timelineBar =
timelineBarRef = el} onClick={(event) => onTimelineClick(timelineBarRef, event)}> + const timelineBar =
{ streamer.vods.map(vod =>
{ { ) })} + + { + (() => { + let timelineBarRef: HTMLDivElement | null = null; + + return (
timelineBarRef = el} onClick={(event) => onTimelineClick(timelineBarRef, event)}> +
+
) + })() + }
}
diff --git a/src/pages/playlists/multistream/playlistMultistream.module.css b/src/pages/playlists/multistream/playlistMultistream.module.css index 87ef268..f049d85 100644 --- a/src/pages/playlists/multistream/playlistMultistream.module.css +++ b/src/pages/playlists/multistream/playlistMultistream.module.css @@ -33,6 +33,7 @@ } .timelineGrid { + position: relative; min-height: 5rem; max-height: 5rem; flex-shrink: 0; @@ -50,13 +51,20 @@ } .timelineBar { - background-color: currentColor; 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); @@ -69,4 +77,24 @@ .offsetInput { width: 5.75rem; +} + +.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; } \ No newline at end of file From 75bb71c3008202c41932ac2a5aecdb60849fbf83 Mon Sep 17 00:00:00 2001 From: Entrivax <1197915+Entrivax@users.noreply.github.com> Date: Sat, 13 Jan 2024 17:22:35 +0100 Subject: [PATCH 4/9] add param to query multistream info from multistream page + fix offset not settable when the streamer is not checked --- src/pages/playlists/multistream/[playlistId].tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/playlists/multistream/[playlistId].tsx b/src/pages/playlists/multistream/[playlistId].tsx index febaa72..c7ea553 100644 --- a/src/pages/playlists/multistream/[playlistId].tsx +++ b/src/pages/playlists/multistream/[playlistId].tsx @@ -44,7 +44,7 @@ const PlaylistMultistream = (props: { playlistId: string }) => { useApi( { method: "GET", - url: `/api/v1/playlist/${props.playlistId}`, + url: `/api/v1/playlist/${props.playlistId}?with_multistream_info=true`, }, false ).then((res) => res?.data), @@ -188,12 +188,12 @@ const PlaylistMultistream = (props: { playlistId: string }) => { const playingVodForStreamer: Record = {}; const playerTiles = Object.keys(streamers).map((streamerId) => { - if (!streamerViewState[streamerId]) { - return null; - } const streamer = streamers[streamerId]; const playingVod = getVodAtTime(streamer.vods, vodPlaybackOffsets, globalTime); playingVodForStreamer[streamerId] = playingVod; + if (!streamerViewState[streamerId]) { + return null; + } if (!playingVod) { return
From 4aa2363ed4c84763d55fb36c37fd0695b11875ee Mon Sep 17 00:00:00 2001 From: Entrivax <1197915+Entrivax@users.noreply.github.com> Date: Sun, 10 Nov 2024 02:19:02 +0100 Subject: [PATCH 5/9] feat(multistream): UI improvements and better time synchronization --- .../Video/MultistreamTimeline.module.css | 71 +++ src/components/Video/MultistreamTimeline.tsx | 191 ++++++ .../Video/SyncedVideoPlayer.module.css | 6 +- src/components/Video/SyncedVideoPlayer.tsx | 156 ++--- src/pages/_app.tsx | 13 + .../playlists/multistream/[playlistId].tsx | 544 +++++++++--------- .../playlistMultistream.module.css | 119 ++-- 7 files changed, 628 insertions(+), 472 deletions(-) create mode 100644 src/components/Video/MultistreamTimeline.module.css create mode 100644 src/components/Video/MultistreamTimeline.tsx diff --git a/src/components/Video/MultistreamTimeline.module.css b/src/components/Video/MultistreamTimeline.module.css new file mode 100644 index 0000000..dba5ce4 --- /dev/null +++ b/src/components/Video/MultistreamTimeline.module.css @@ -0,0 +1,71 @@ +.timelineGrid { + position: relative; + min-height: 5rem; + max-height: 5rem; + 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); +} + +.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..579d0be --- /dev/null +++ b/src/components/Video/MultistreamTimeline.tsx @@ -0,0 +1,191 @@ +import { ActionIcon, Center, Group, NumberInput, Stack, Tooltip } from "@mantine/core"; +import classes from "./MultistreamTimeline.module.css" +import { IconEye, IconEyeOff, IconPlayerPauseFilled, IconPlayerPlayFilled, IconRewindBackward5, IconRewindBackward60, IconRewindForward5, IconRewindForward60 } from "@tabler/icons-react"; +import React, { Fragment, useState } from "react"; +import dayjs from "dayjs"; + +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; + streamerViewState: Record; + toggleView: (streamerId: string) => void; +} + +type Vod = { + id: string + duration: number + streamed_at: string +} + +export const MultistreamTimeline = ({ vodPlaybackOffsets, globalTime, startDateMs, endDateMs, playStartAtDate, seek, pause, play, playing, playingVodForStreamer, streamerViewState, toggleView, streamers, setVodOffset }: MultistreamTimelineProps) => { + 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 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 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)} + > + + + +
+
+ {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 ( + +
+ {streamer.name} + + toggleView(streamerId)} aria-label={streamerViewState[streamerId] ? "Hide streamer" : "Show streamer"}> + {streamerViewState[streamerId] ? : } + + + { + 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 &&
} +
+
+ })() + } +
+ } +
+} \ No newline at end of file diff --git a/src/components/Video/SyncedVideoPlayer.module.css b/src/components/Video/SyncedVideoPlayer.module.css index d186873..35628a1 100644 --- a/src/components/Video/SyncedVideoPlayer.module.css +++ b/src/components/Video/SyncedVideoPlayer.module.css @@ -1,7 +1,11 @@ .mediaPlayer { - height: 100%; + 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 index 78197c0..2d3578a 100644 --- a/src/components/Video/SyncedVideoPlayer.tsx +++ b/src/components/Video/SyncedVideoPlayer.tsx @@ -5,115 +5,71 @@ 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" -import { useInterval } from "@mantine/hooks"; export type SyncedVideoPlayerProps = { - src: string; - vodId: string; - title: string; - poster: string; - time: number; - playing: boolean; - muted: boolean; - onUserSeek: (time: number) => void; - onUserPlay: () => void; - onUserPause: (pausedAtTime: number) => void; - onTimeUpdate: (currentTime: number) => void; + src: string; + vodId: string; + title: string; + poster: string; + time: number; + playing: boolean; + muted: boolean; } -const SyncedVideoPlayer = ({ src, vodId, title, poster, time, playing, muted, onUserSeek, onUserPlay, onUserPause, onTimeUpdate }: SyncedVideoPlayerProps) => { - const { publicRuntimeConfig } = getConfig(); - const player = useRef(null) - const mediaProvider = useRef(null) - const [canPlay, setCanPlay] = useState(false) - const [justSynchonized, setJustSynchronized] = useState(false) +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) - const onPlay = () => { - if (!player.current || playing) return; - console.log(`vodId: ${vodId} playing`) - onUserPlay() - } + 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]) - const onPause = () => { - if (!player.current || !playing) return; - console.log(`vodId: ${vodId} pausing`) - onUserPause(player.current.currentTime) - } + useEffect(() => { + if (!player.current) return; + player.current.muted = muted; + }, [muted]) - useEffect(() => { - const currentPlayer = player.current - if (!currentPlayer || !canPlay) return; - console.log(`vodId: ${vodId} playing=${playing}`); - (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 || Math.abs(player.current.currentTime - time) < 0.2) return; + player.current.currentTime = time; + }, [time]) - useEffect(() => { - if (!player.current) return; - player.current.muted = muted; - }, [muted]) + return ( + setCanPlay(true)} + playsInline + muted={muted} + > + + + + - const timeUpdateInterval = useInterval(() => { - // Don't update if it's a rounding frame time problem - if (!player.current || Math.abs(player.current.currentTime - time) < 0.01) return; - onTimeUpdate(player.current.currentTime); - }, 2000) - - useEffect(() => { - if (!player.current) return; - // Synchronize time if in pause or out of sync by half a second - if (Math.abs(player.current.currentTime - time) > 0.5 || !playing) { - player.current.currentTime = time; - setJustSynchronized(true); - } - timeUpdateInterval.start(); - return timeUpdateInterval.stop; - }, [time]) - - const onSeeking = (currentTime: number) => { - if (!player.current) return; - setJustSynchronized((justSynchonized) => { - if (justSynchonized) { - return false; - } - onUserSeek(currentTime); - return false; - }) - } - - return ( - setCanPlay(true)} - playsinline - > - - - - - - - - ) + + + ) }; 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 index c7ea553..b8ad1f3 100644 --- a/src/pages/playlists/multistream/[playlistId].tsx +++ b/src/pages/playlists/multistream/[playlistId].tsx @@ -1,326 +1,294 @@ import getConfig from "next/config"; import useUserStore from "../../../store/user"; import Head from "next/head"; -import VodLoginRequired from "../../../components/Vod/LoginRequred"; +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, { Fragment, useEffect, useState } from "react"; -import SyncedVideoPlayer from "../../../components/Vod/SyncedVideoPlayer"; +import React, { useEffect, useState } from "react"; +import SyncedVideoPlayer from "../../../components/Video/SyncedVideoPlayer"; import { escapeURL } from "../../../util/util"; import classes from "./playlistMultistream.module.css" -import { ActionIcon, Flex, NumberInput, Text } from "@mantine/core"; -import { IconEye, IconEyeOff } from "@tabler/icons-react"; +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 { playlistId } = context.query; + return { + props: { + playlistId, + }, + }; +} const PlaylistMultistream = (props: { playlistId: string }) => { - const { publicRuntimeConfig } = getConfig(); - const user = useUserStore((state) => state); - 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}/delay`, - withCredentials: true, - data: { - vod_id: vodId, - delay_ms: delayMs, - } - }, - false - ).catch((err) => { - console.log("Error saving delay", err); - }); + const { publicRuntimeConfig } = getConfig(); + const user = useUserStore((state) => state); + 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`, }, - }) - - const [playing, setPlaying] = useState(false); - const [globalTime, setGlobalTime] = useState(0); - const [seeked, setSeeked] = useState(false); - const [startDateMs, setStartDateMs] = useState(null); - const [endDateMs, setEndDateMs] = useState(null); - const [streamers, setStreamers] = useState>({}); - - 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(vod.streamed_at) - 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: [] - } - } - - _streamers[vod.edges.channel.id].vods.push(vod) + 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}/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 [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(vod.streamed_at) + 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: [] } - 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]) + } - if (error) return
failed to load
; - if (isLoading) return ; - if (data.edges.vods.length === 0) return
Empty playlist, unable to start multistream.
; - - let timelineDurationMs: number = startDateMs != null && endDateMs != null ? endDateMs - startDateMs : 0; - if (seeked) - setSeeked(false); - - const seek = (newGlobalTime: number) => { - setSeeked(true); - setGlobalTime(newGlobalTime); + _streamers[vod.edges.channel.id].vods.push(vod) } + 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]) - if (startDateMs != null && globalTime < startDateMs) { - seek(startDateMs); + 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); + } - const onUserPlay = () => { - setPlaying(true); - } + if (startDateMs != null && globalTime < startDateMs) { + seek(startDateMs); + } - const onUserPause = (pausedAtGlobalTime: number) => { - setPlaying(false); - setGlobalTime(pausedAtGlobalTime) - } + const onUserPlay = () => { + setPlayStartAtDate(Date.now()); + setPlaying(true); + } - const onUserSeek = (time: number) => { - seek(time); - } + const onUserPause = (pausedAtGlobalTime: number) => { + setPlaying(false); + setGlobalTime(pausedAtGlobalTime) + setGlobalTimeUpdate(pausedAtGlobalTime); + } - const onTimeUpdate = (currentTime: number) => { - setGlobalTime(currentTime); - } + const toggleView = (streamerId: string) => { + setStreamerViewState((prevState) => { + const newState = { ...prevState }; + newState[streamerId] = !newState[streamerId]; + return newState; + }) + } - 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 playingVodForStreamer: Record = {}; - const onTimelineClick = (timelineBar: HTMLDivElement | null, event: React.MouseEvent) => { - const newGlobalTime = timeAtMousePosition(timelineBar, event); - if (newGlobalTime == null) { - return; - } - seek(newGlobalTime); + const playerTiles = Object.keys(streamers).map((streamerId) => { + const streamer = streamers[streamerId]; + const playingVod = getVodAtTime(streamer.vods, vodPlaybackOffsets, globalTimeUpdate); + playingVodForStreamer[streamerId] = playingVod; + if (!streamerViewState[streamerId]) { + return null; } - - const toggleView = (streamerId: string) => { - setStreamerViewState((prevState) => { - const newState = { ...prevState }; - newState[streamerId] = !newState[streamerId]; - return newState; - }) + if (!playingVod) { + return
+ + {streamer.name}
+ OFFLINE +
+
} - - const playingVodForStreamer: Record = {}; - - const playerTiles = Object.keys(streamers).map((streamerId) => { - const streamer = streamers[streamerId]; - const playingVod = getVodAtTime(streamer.vods, vodPlaybackOffsets, globalTime); - playingVodForStreamer[streamerId] = playingVod; - if (!streamerViewState[streamerId]) { - return null; - } - if (!playingVod) { - return
- - { streamer.name }
- OFFLINE -
-
- } - const playbackOffset = (vodPlaybackOffsets[playingVod.id] || 0) / 1000; - const vodTime = (globalTime - (+new Date(playingVod?.streamed_at))) / 1000 + playbackOffset; - const toGlobalTime = (time: number) => (time - playbackOffset) * 1000 + (+new Date(playingVod.streamed_at)); - return ( -
- onUserSeek(toGlobalTime(time))} - onUserPlay={onUserPlay} - onUserPause={(pausedAtTime) => onUserPause(toGlobalTime(pausedAtTime))} - onTimeUpdate={(currentTime) => onTimeUpdate(toGlobalTime(currentTime))} - /> -
- ) - }) - + const playbackOffset = (vodPlaybackOffsets[playingVod.id] || 0) / 1000; + const currentGlobalTime = (playing ? (Date.now() - playStartAtDate) : 0) + globalTime + const vodTime = (currentGlobalTime - (+new Date(playingVod?.streamed_at))) / 1000 + playbackOffset; return ( -
- - {data.name} - Ganymede - - { checkLoginRequired() && || -
-
acc + +(player != null), 0) } as React.CSSProperties}> - { playerTiles } -
- { - startDateMs != null && endDateMs != null &&
- { Object.keys(streamers).map((streamerId) => { - const streamer = streamers[streamerId] - - const timelineBar =
- { streamer.vods.map(vod =>
) } -
- - const playingVod = playingVodForStreamer[streamerId]; - - return ( - -
- { streamer.name } - toggleView(streamerId)}> - { streamerViewState[streamerId] ? : } - - { - if (!playingVod) return; - const valAsNumber = Math.trunc(+value * 1000); - if (isNaN(valAsNumber)) return; - setVodPlaybackOffsets((prevState) => { - const newState = { ...prevState }; - newState[playingVod.id] = valAsNumber; - return newState; - }) - updateVodOffset.mutate({ - playlistId: props.playlistId, - vodId: playingVod.id, - delayMs: valAsNumber, - }) - }} - /> -
-
{ timelineBar }
-
- ) - })} - - { - (() => { - let timelineBarRef: HTMLDivElement | null = null; - - return (
timelineBarRef = el} onClick={(event) => onTimelineClick(timelineBarRef, event)}> -
-
) - })() - } -
- } -
- } -
+
+ +
) + }) + + return ( +
+ + {data.name} - Ganymede Multistream + + {checkLoginRequired() && || +
+
acc + +(player != null), 0) } as React.CSSProperties}> + {playerTiles} +
+
+ + + +
+ + { onUserPause(globalTime + Date.now() - playStartAtDate) }} + endDateMs={endDateMs} + startDateMs={startDateMs} + globalTime={globalTime} + playing={playing} + playingVodForStreamer={playingVodForStreamer} + streamers={streamers} + streamerViewState={streamerViewState} + toggleView={toggleView} + 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} + /> + +
+ } +
+ ) } type Vod = { - id: string - video_path: string - duration: number - streamed_at: string - title: string + id: string + web_thumbnail_path: string + video_path: string + duration: number + streamed_at: string + title: string } 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(vod.streamed_at) - const vodEndDateMs = vodStartDateMs + vod.duration * 1000; - if (vodStartDateMs <= offsettedTime && offsettedTime <= vodEndDateMs) { - return vod; - } + 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(vod.streamed_at) + const vodEndDateMs = vodStartDateMs + vod.duration * 1000; + if (vodStartDateMs <= offsettedTime && offsettedTime <= vodEndDateMs) { + return vod; } - return null; + } + return null; } 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 index f049d85..fd3c0b0 100644 --- a/src/pages/playlists/multistream/playlistMultistream.module.css +++ b/src/pages/playlists/multistream/playlistMultistream.module.css @@ -1,100 +1,53 @@ .pageWrapper { - height: calc(100vh - 60px); - display: flex; - flex-direction: column; - gap: 1rem; + height: calc(100vh - 60px); + display: flex; + flex-direction: column; + gap: 1rem; } .videosGrid { - flex: 1; - max-height: 100%; - display: flex; - flex-wrap: wrap; + flex: 1; + max-height: 100%; + display: flex; + flex-wrap: wrap; } .playerTile { - --max-per-row: 2; - width: calc(100% / min(var(--players-count), var(--max-per-row))); - max-height: 100%; + --max-per-row: 2; + width: calc(100% / min(var(--players-count), var(--max-per-row))); + max-height: 100%; - @media (min-aspect-ratio: 1.9) { - --max-per-row: 3; - } - @media (max-aspect-ratio: 0.9) { - --max-per-row: 1; - } -} - -.streamerOffline { - display: flex; - justify-content: center; - align-items: center; - text-align: center; -} - -.timelineGrid { - position: relative; - min-height: 5rem; - max-height: 5rem; - flex-shrink: 0; - overflow: auto; - display: grid; - grid-template-columns: auto 1fr; - align-items: center; - gap: 0.25rem; - margin: 0 1rem 1rem 1rem; -} + @media (min-aspect-ratio: 1.9) { + --max-per-row: 3; + } -.timelineStreamerColumn { - display: flex; - gap: 0.25rem; + @media (max-aspect-ratio: 0.9) { + --max-per-row: 1; + } } -.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); +.streamerOffline { + display: flex; + justify-content: center; + align-items: center; + text-align: center; } -.offsetInput { - width: 5.75rem; -} +.timelineOpenButtonContainer { + display: flex; + justify-content: center; + position: fixed; + bottom: 0; + pointer-events: none; + z-index: 150; + width: 100vw; -.playheadContainer { - position: absolute; - grid-column: 2 / 3; - grid-row: 1; - width: 100%; - height: 100%; + &>* { + pointer-events: auto; + } } -.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; +.timelineOpenButton { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; } \ No newline at end of file From 3aecdcb281a4b97dcf0abdcae9a6584cc9292ab1 Mon Sep 17 00:00:00 2001 From: Entrivax <1197915+Entrivax@users.noreply.github.com> Date: Sun, 10 Nov 2024 17:42:01 +0100 Subject: [PATCH 6/9] feat(multistream): Better layout customization --- .../Video/MultistreamTimeline.module.css | 6 +- src/components/Video/MultistreamTimeline.tsx | 84 ++++--- .../playlists/multistream/[playlistId].tsx | 216 ++++++++++++++++-- .../playlistMultistream.module.css | 102 +++++++-- 4 files changed, 345 insertions(+), 63 deletions(-) diff --git a/src/components/Video/MultistreamTimeline.module.css b/src/components/Video/MultistreamTimeline.module.css index dba5ce4..5c04e77 100644 --- a/src/components/Video/MultistreamTimeline.module.css +++ b/src/components/Video/MultistreamTimeline.module.css @@ -1,7 +1,5 @@ .timelineGrid { position: relative; - min-height: 5rem; - max-height: 5rem; flex-shrink: 0; overflow: auto; display: grid; @@ -41,6 +39,10 @@ width: var(--bar-length); } +.gridInput { + width: 4.5rem; +} + .offsetInput { width: 4.5rem; } diff --git a/src/components/Video/MultistreamTimeline.tsx b/src/components/Video/MultistreamTimeline.tsx index 579d0be..77fceb8 100644 --- a/src/components/Video/MultistreamTimeline.tsx +++ b/src/components/Video/MultistreamTimeline.tsx @@ -1,8 +1,9 @@ -import { ActionIcon, Center, Group, NumberInput, Stack, Tooltip } from "@mantine/core"; +import { ActionIcon, Center, Group, Image, NumberInput, Stack, Tooltip } from "@mantine/core"; import classes from "./MultistreamTimeline.module.css" -import { IconEye, IconEyeOff, IconPlayerPauseFilled, IconPlayerPlayFilled, IconRewindBackward5, IconRewindBackward60, IconRewindForward5, IconRewindForward60 } from "@tabler/icons-react"; -import React, { Fragment, useState } from "react"; +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; @@ -18,10 +19,14 @@ export type MultistreamTimelineProps = { playingVodForStreamer: Record; streamers: Record; - streamerViewState: Record; - toggleView: (streamerId: string) => void; + gridWidth: number; + gridHeight: number; + setGridWidth: (width: number) => void; + setGridHeight: (height: number) => void; + onStreamerDragStart: () => void; } type Vod = { @@ -30,7 +35,8 @@ type Vod = { streamed_at: string } -export const MultistreamTimeline = ({ vodPlaybackOffsets, globalTime, startDateMs, endDateMs, playStartAtDate, seek, pause, play, playing, playingVodForStreamer, streamerViewState, toggleView, streamers, setVodOffset }: MultistreamTimelineProps) => { +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) => { @@ -42,6 +48,8 @@ export const MultistreamTimeline = ({ vodPlaybackOffsets, globalTime, startDateM return globalTime; } + const streamerImageRefs = useRef>({}); + const onTimelineClick = (timelineBar: HTMLDivElement | null, event: React.MouseEvent) => { const newGlobalTime = timeAtMousePosition(timelineBar, event); if (newGlobalTime == null) { @@ -67,6 +75,15 @@ export const MultistreamTimeline = ({ vodPlaybackOffsets, globalTime, startDateM 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 @@ -122,12 +139,17 @@ export const MultistreamTimeline = ({ vodPlaybackOffsets, globalTime, startDateM onClick={() => 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} -
+ +
+ {dayjs(getCurrentTime()).format("YYYY/MM/DD HH:mm:ss")} / {timelineEnd} +
+
{ @@ -147,26 +169,28 @@ export const MultistreamTimeline = ({ vodPlaybackOffsets, globalTime, startDateM return (
- {streamer.name} - - toggleView(streamerId)} aria-label={streamerViewState[streamerId] ? "Hide streamer" : "Show streamer"}> - {streamerViewState[streamerId] ? : } - - - { - if (!playingVod) return; - const valAsNumber = Math.trunc(+value * 1000); - if (isNaN(valAsNumber)) return; - setVodOffset(playingVod.id, valAsNumber); - }} - /> + +
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}
diff --git a/src/pages/playlists/multistream/[playlistId].tsx b/src/pages/playlists/multistream/[playlistId].tsx index b8ad1f3..e759770 100644 --- a/src/pages/playlists/multistream/[playlistId].tsx +++ b/src/pages/playlists/multistream/[playlistId].tsx @@ -5,7 +5,7 @@ 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, { useEffect, useState } from "react"; +import React, { ReactNode, useEffect, useState } from "react"; import SyncedVideoPlayer from "../../../components/Video/SyncedVideoPlayer"; import { escapeURL } from "../../../util/util"; import classes from "./playlistMultistream.module.css" @@ -26,7 +26,8 @@ export async function getServerSideProps(context: any) { const PlaylistMultistream = (props: { playlistId: string }) => { const { publicRuntimeConfig } = getConfig(); const user = useUserStore((state) => state); - const [streamerViewState, setStreamerViewState] = useState>({}); + const videoGrid = React.createRef(); + const [streamerViewState, setStreamerViewState] = useState>({}); const [vodPlaybackOffsets, setVodPlaybackOffsets] = useState>({}); const checkLoginRequired = () => { @@ -80,8 +81,16 @@ const PlaylistMultistream = (props: { playlistId: string }) => { 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); @@ -98,7 +107,8 @@ const PlaylistMultistream = (props: { playlistId: string }) => { let _endDateMs: number | null = null; let _streamers: Record = {}; for (let i = 0; i < data.edges.vods.length; i++) { const vod = data.edges.vods[i]; @@ -114,7 +124,8 @@ const PlaylistMultistream = (props: { playlistId: string }) => { if (!_streamers[vod.edges.channel.id]) { _streamers[vod.edges.channel.id] = { name: vod.edges.channel.name, - vods: [] + vods: [], + imagePath: vod.edges.channel.image_path, } } @@ -170,36 +181,116 @@ const PlaylistMultistream = (props: { playlistId: string }) => { setGlobalTimeUpdate(pausedAtGlobalTime); } - const toggleView = (streamerId: string) => { + 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[streamerId] = !newState[streamerId]; + newState[resizeOverlayParams.streamerId] = { + tileX: resizeOverlayParams.tileX, + tileY: resizeOverlayParams.tileY, + tileWidth: resizeOverlayParams.tileWidth, + tileHeight: resizeOverlayParams.tileHeight, + }; return newState; }) } const playingVodForStreamer: Record = {}; - const playerTiles = Object.keys(streamers).map((streamerId) => { + const playerTiles = Object.keys(streamerViewState).map((streamerId) => { const streamer = streamers[streamerId]; - const playingVod = getVodAtTime(streamer.vods, vodPlaybackOffsets, globalTimeUpdate); - playingVodForStreamer[streamerId] = playingVod; - if (!streamerViewState[streamerId]) { + const viewState = streamerViewState[streamerId]; + if (!viewState || !streamer) { return null; } + const playingVod = getVodAtTime(streamer.vods, vodPlaybackOffsets, globalTimeUpdate); + playingVodForStreamer[streamerId] = playingVod; if (!playingVod) { - return
+ 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(playingVod?.streamed_at))) / 1000 + playbackOffset; return ( -
+ startMovingTile(streamerId)} + startResizing={() => startResizingTile(streamerId)} + remove={() => { + setStreamerViewState((prevState) => { + const newState = { ...prevState }; + delete newState[streamerId]; + return newState; + }) + }} + > { playing={playing} muted={true} /> -
+ ) }) + 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 (
@@ -220,8 +370,18 @@ const PlaylistMultistream = (props: { playlistId: string }) => { {checkLoginRequired() && ||
-
acc + +(player != null), 0) } as React.CSSProperties}> +
{ leaveDropZone(); }} + onDragEnter={(event) => { checkDropData(event); }} + onPointerUp={() => { endTileResize() }} + onPointerMove={(event) => { if (resizeMode) checkResize(event) }} + > {playerTiles} + {dropTiles} + {resizeMode &&
}
{ playing={playing} playingVodForStreamer={playingVodForStreamer} streamers={streamers} - streamerViewState={streamerViewState} - toggleView={toggleView} playStartAtDate={playStartAtDate} seek={seek} setVodOffset={(vodId, offset) => { @@ -260,6 +418,11 @@ const PlaylistMultistream = (props: { playlistId: string }) => { }) }} vodPlaybackOffsets={vodPlaybackOffsets} + onStreamerDragStart={() => { close() }} + gridWidth={gridWidth} + gridHeight={gridHeight} + setGridWidth={(width) => { setGridWidth(width) }} + setGridHeight={(height) => { setGridHeight(height) }} />
@@ -291,4 +454,21 @@ function getVodAtTime(vods: Vod[], vodPlaybackOffsets: Record, t 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 index fd3c0b0..7652cd1 100644 --- a/src/pages/playlists/multistream/playlistMultistream.module.css +++ b/src/pages/playlists/multistream/playlistMultistream.module.css @@ -1,36 +1,112 @@ .pageWrapper { height: calc(100vh - 60px); display: flex; - flex-direction: column; - gap: 1rem; } .videosGrid { flex: 1; - max-height: 100%; - display: flex; - flex-wrap: wrap; + 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; + } } -.playerTile { - --max-per-row: 2; - width: calc(100% / min(var(--players-count), var(--max-per-row))); - max-height: 100%; +.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; +} - @media (min-aspect-ratio: 1.9) { - --max-per-row: 3; +.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; } - @media (max-aspect-ratio: 0.9) { - --max-per-row: 1; + & > .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 { From 920db90bf6f6b0cb5f0335f5e17e7c16b63256e4 Mon Sep 17 00:00:00 2001 From: Entrivax <1197915+Entrivax@users.noreply.github.com> Date: Sun, 10 Nov 2024 22:27:37 +0100 Subject: [PATCH 7/9] feat(multistream): use the vod type to either use `streamed_at` or `created_at` date --- src/components/Video/MultistreamTimeline.tsx | 14 +++++++++++++- .../playlists/multistream/[playlistId].tsx | 18 +++++++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/components/Video/MultistreamTimeline.tsx b/src/components/Video/MultistreamTimeline.tsx index 77fceb8..373d80d 100644 --- a/src/components/Video/MultistreamTimeline.tsx +++ b/src/components/Video/MultistreamTimeline.tsx @@ -33,6 +33,8 @@ 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) => { @@ -159,7 +161,7 @@ export const MultistreamTimeline = ({ vodPlaybackOffsets, globalTime, startDateM const timelineBar =
{streamer.vods.map(vod =>
)}
@@ -212,4 +214,14 @@ export const MultistreamTimeline = ({ vodPlaybackOffsets, globalTime, startDateM
} +} + +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/pages/playlists/multistream/[playlistId].tsx b/src/pages/playlists/multistream/[playlistId].tsx index e759770..95f0456 100644 --- a/src/pages/playlists/multistream/[playlistId].tsx +++ b/src/pages/playlists/multistream/[playlistId].tsx @@ -112,7 +112,7 @@ const PlaylistMultistream = (props: { playlistId: string }) => { }> = {}; for (let i = 0; i < data.edges.vods.length; i++) { const vod = data.edges.vods[i]; - const vodStartDateMs = +new Date(vod.streamed_at) + const vodStartDateMs = +new Date(getVodStartDate(vod)) if (_startDateMs == null || vodStartDateMs < _startDateMs) { _startDateMs = vodStartDateMs; } @@ -275,7 +275,7 @@ const PlaylistMultistream = (props: { playlistId: string }) => { } const playbackOffset = (vodPlaybackOffsets[playingVod.id] || 0) / 1000; const currentGlobalTime = (playing ? (Date.now() - playStartAtDate) : 0) + globalTime - const vodTime = (currentGlobalTime - (+new Date(playingVod?.streamed_at))) / 1000 + playbackOffset; + const vodTime = (currentGlobalTime - (+new Date(getVodStartDate(playingVod)))) / 1000 + playbackOffset; return ( , 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(vod.streamed_at) + const vodStartDateMs = +new Date(getVodStartDate(vod)) const vodEndDateMs = vodStartDateMs + vod.duration * 1000; if (vodStartDateMs <= offsettedTime && offsettedTime <= vodEndDateMs) { return vod; From 1256e97f9e7a8dd5fc0014da7d7c6b5e57be78c3 Mon Sep 17 00:00:00 2001 From: Entrivax <1197915+Entrivax@users.noreply.github.com> Date: Sun, 10 Nov 2024 22:37:03 +0100 Subject: [PATCH 8/9] feat(multistream): rename route and method Co-authored-by: Zibbp --- src/pages/playlists/multistream/[playlistId].tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/playlists/multistream/[playlistId].tsx b/src/pages/playlists/multistream/[playlistId].tsx index 95f0456..4f17d89 100644 --- a/src/pages/playlists/multistream/[playlistId].tsx +++ b/src/pages/playlists/multistream/[playlistId].tsx @@ -59,7 +59,7 @@ const PlaylistMultistream = (props: { playlistId: string }) => { await useApi( { method: "PUT", - url: `/api/v1/playlist/${playlistId}/delay`, + url: `/api/v1/playlist/${playlistId}/multistream/delay`, withCredentials: true, data: { vod_id: vodId, From 51dc2da7b1725c28209febfd812faa0de3a7a6c6 Mon Sep 17 00:00:00 2001 From: Entrivax <1197915+Entrivax@users.noreply.github.com> Date: Sun, 10 Nov 2024 23:32:50 +0100 Subject: [PATCH 9/9] feat(multistream): add default view --- .../playlists/multistream/[playlistId].tsx | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/pages/playlists/multistream/[playlistId].tsx b/src/pages/playlists/multistream/[playlistId].tsx index 4f17d89..0261eb2 100644 --- a/src/pages/playlists/multistream/[playlistId].tsx +++ b/src/pages/playlists/multistream/[playlistId].tsx @@ -131,6 +131,29 @@ const PlaylistMultistream = (props: { playlistId: string }) => { _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)