From d09bc91de653195a2675a72c7129420d864ef680 Mon Sep 17 00:00:00 2001 From: ElementalAlchemist Date: Thu, 14 Nov 2024 18:38:02 -0600 Subject: [PATCH] Add restreamer links to download video and frames --- thrimbletrimmer/src/common/convertTime.tsx | 28 +++++++++++ thrimbletrimmer/src/common/video.module.scss | 2 +- thrimbletrimmer/src/common/video.scss | 6 ++- thrimbletrimmer/src/common/video.tsx | 16 ++++--- thrimbletrimmer/src/globalStyle.scss | 2 +- .../src/restreamer/Restreamer.module.scss | 4 ++ thrimbletrimmer/src/restreamer/Restreamer.tsx | 48 ++++++++++++++++++- 7 files changed, 94 insertions(+), 12 deletions(-) diff --git a/thrimbletrimmer/src/common/convertTime.tsx b/thrimbletrimmer/src/common/convertTime.tsx index 0002bd9..9a59cb8 100644 --- a/thrimbletrimmer/src/common/convertTime.tsx +++ b/thrimbletrimmer/src/common/convertTime.tsx @@ -1,4 +1,6 @@ import { DateTime } from "luxon"; +import { HLSProvider } from "vidstack"; +import { Fragment } from "hls.js"; export enum TimeType { UTC, @@ -99,3 +101,29 @@ export function timeAgoFromDateTime(dateTime: DateTime): string { return `${negative}${hours}:${minutesString}:${secondsString}`; } + +export function dateTimeFromVideoPlayerTime( + videoProvider: HLSProvider, + videoTime: number, +): DateTime | null { + // We do a little bit of cheating our way into private data. The standard way of getting this involves + // handling events and capturing and saving a fragments array from it, which seems overly cumbersome. + const fragments = (videoProvider.instance as any).latencyController.levelDetails + .fragments as Fragment[]; + let fragmentStartTime: number | undefined = undefined; + let fragmentStartISOTime: string | undefined = undefined; + for (const fragment of fragments) { + const fragmentEndTime = fragment.start + fragment.duration; + if (videoTime >= fragment.start && videoTime < fragmentEndTime) { + fragmentStartTime = fragment.start; + fragmentStartISOTime = fragment.rawProgramDateTime; + break; + } + } + if (fragmentStartISOTime === undefined) { + return null; + } + const wubloaderTime = DateTime.fromISO(fragmentStartISOTime); + const offset = videoTime - fragmentStartTime; + return wubloaderTime.plus({ seconds: offset }); +} diff --git a/thrimbletrimmer/src/common/video.module.scss b/thrimbletrimmer/src/common/video.module.scss index f40dd5c..f543f05 100644 --- a/thrimbletrimmer/src/common/video.module.scss +++ b/thrimbletrimmer/src/common/video.module.scss @@ -8,4 +8,4 @@ .streamTimeSettingLabel { margin-right: 3px; -} \ No newline at end of file +} diff --git a/thrimbletrimmer/src/common/video.scss b/thrimbletrimmer/src/common/video.scss index 953d1e4..594fdc4 100644 --- a/thrimbletrimmer/src/common/video.scss +++ b/thrimbletrimmer/src/common/video.scss @@ -3,7 +3,9 @@ media-player { flex-direction: column; } -media-provider, media-captions, video { +media-provider, +media-captions, +video { max-width: 100%; max-height: 50vh; } @@ -26,4 +28,4 @@ media-volume-slider { .vds-slider-track-fill { background-color: #f6f6f6; -} \ No newline at end of file +} diff --git a/thrimbletrimmer/src/common/video.tsx b/thrimbletrimmer/src/common/video.tsx index dccf940..08cd42a 100644 --- a/thrimbletrimmer/src/common/video.tsx +++ b/thrimbletrimmer/src/common/video.tsx @@ -21,6 +21,7 @@ import { } from "./convertTime"; import styles from "./video.module.scss"; import "./video.scss"; +import { HLSProvider } from "vidstack"; import { MediaPlayerElement } from "vidstack/elements"; import "vidstack/icons"; @@ -200,12 +201,14 @@ export const StreamTimeSettings: Component = (props) => export interface VideoPlayerProps { src: Accessor; + setPlayerTime: Setter; + mediaPlayer: Accessor; + setMediaPlayer: Setter; } export const VideoPlayer: Component = (props) => { - let [mediaPlayer, setMediaPlayer] = createSignal(); createEffect(() => { - const player = mediaPlayer(); + const player = props.mediaPlayer(); const srcURL = props.src(); player.src = srcURL; }); @@ -214,9 +217,10 @@ export const VideoPlayer: Component = (props) => { let [duration, setDuration] = createSignal(0); onMount(() => { - const player = mediaPlayer(); + const player = props.mediaPlayer(); player.subscribe(({ currentTime, duration }) => { setPlayerTime(currentTime); + props.setPlayerTime(currentTime); setDuration(duration); }); player.streamType = "on-demand"; @@ -243,14 +247,14 @@ export const VideoPlayer: Component = (props) => { return ( - { - const player = mediaPlayer(); + const player = props.mediaPlayer(); if (player.paused) { player.play(event); } else { diff --git a/thrimbletrimmer/src/globalStyle.scss b/thrimbletrimmer/src/globalStyle.scss index c40968a..b831c74 100644 --- a/thrimbletrimmer/src/globalStyle.scss +++ b/thrimbletrimmer/src/globalStyle.scss @@ -43,4 +43,4 @@ a, .hidden { display: none; -} \ No newline at end of file +} diff --git a/thrimbletrimmer/src/restreamer/Restreamer.module.scss b/thrimbletrimmer/src/restreamer/Restreamer.module.scss index d00753c..506cba0 100644 --- a/thrimbletrimmer/src/restreamer/Restreamer.module.scss +++ b/thrimbletrimmer/src/restreamer/Restreamer.module.scss @@ -19,3 +19,7 @@ top: 0; right: 0; } + +.videoLinks a { + margin: 2px; +} diff --git a/thrimbletrimmer/src/restreamer/Restreamer.tsx b/thrimbletrimmer/src/restreamer/Restreamer.tsx index ff662e9..9eceecb 100644 --- a/thrimbletrimmer/src/restreamer/Restreamer.tsx +++ b/thrimbletrimmer/src/restreamer/Restreamer.tsx @@ -10,8 +10,14 @@ import { Suspense, } from "solid-js"; import { DateTime } from "luxon"; +import { HLSProvider } from "vidstack"; +import { MediaPlayerElement } from "vidstack/elements"; import styles from "./Restreamer.module.scss"; -import { dateTimeFromWubloaderTime, wubloaderTimeFromDateTime } from "../common/convertTime"; +import { + dateTimeFromVideoPlayerTime, + dateTimeFromWubloaderTime, + wubloaderTimeFromDateTime, +} from "../common/convertTime"; import { KeyboardShortcuts, StreamTimeSettings, @@ -92,6 +98,8 @@ const RestreamerWithDefaults: Component = (props) => { streamStartTime: DateTime.utc().minus({ minutes: 10 }), streamEndTime: null, }); + const [playerTime, setPlayerTime] = createSignal(0); + const [mediaPlayer, setMediaPlayer] = createSignal(); const videoURL = () => { const streamInfo = streamVideoInfo(); @@ -109,6 +117,33 @@ const RestreamerWithDefaults: Component = (props) => { return url; }; + const downloadVideoURL = () => { + const streamInfo = streamVideoInfo(); + const startTime = wubloaderTimeFromDateTime(streamInfo.streamStartTime); + const params = new URLSearchParams({ type: "smart", start: encodeURIComponent(startTime) }); + if (streamInfo.streamEndTime) { + const endTime = wubloaderTimeFromDateTime(streamInfo.streamEndTime); + params.append("end", endTime); + } + return `/cut/${streamInfo.streamName}/source.ts?${params}`; + }; + + const downloadFrameURL = () => { + const streamInfo = streamVideoInfo(); + const player = mediaPlayer(); + const videoTime = playerTime(); + const provider = player.provider as HLSProvider; + if (!provider) { + return ""; + } + const currentTime = dateTimeFromVideoPlayerTime(provider, videoTime); + if (currentTime === null) { + return ""; + } + const wubloaderTime = wubloaderTimeFromDateTime(currentTime); + return `/frame/${streamInfo.streamName}/source.png?timestamp=${wubloaderTime}`; + }; + return ( <> = (props) => { errorList={props.errorList} setErrorList={props.setErrorList} /> - + + ); };