mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-04-25 06:35:18 +00:00
feat: download files, show cloud video, solf deletion with no reprocessing (#920)
* fix: move upd ports out of MacOS internal Range * feat: download files, show cloud video, solf deletion with no reprocessing
This commit is contained in:
committed by
GitHub
parent
cb1beae90d
commit
a76f114378
@@ -5,10 +5,11 @@ import useWaveform from "../useWaveform";
|
||||
import useMp3 from "../useMp3";
|
||||
import { TopicList } from "./_components/TopicList";
|
||||
import { Topic } from "../webSocketTypes";
|
||||
import React, { useEffect, useState, use } from "react";
|
||||
import React, { useEffect, useState, useCallback, use } from "react";
|
||||
import FinalSummary from "./finalSummary";
|
||||
import TranscriptTitle from "../transcriptTitle";
|
||||
import Player from "../player";
|
||||
import VideoPlayer from "../videoPlayer";
|
||||
import { useWebSockets } from "../useWebSockets";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { parseNonEmptyString } from "../../../lib/utils";
|
||||
@@ -56,6 +57,21 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
const [finalSummaryElement, setFinalSummaryElement] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
|
||||
const hasCloudVideo = !!transcript.data?.has_cloud_video;
|
||||
const [videoExpanded, setVideoExpanded] = useState(false);
|
||||
const [videoNewBadge, setVideoNewBadge] = useState(() => {
|
||||
if (typeof window === "undefined") return true;
|
||||
return !localStorage.getItem(`video-seen-${transcriptId}`);
|
||||
});
|
||||
|
||||
const handleVideoToggle = useCallback(() => {
|
||||
setVideoExpanded((prev) => !prev);
|
||||
if (videoNewBadge) {
|
||||
setVideoNewBadge(false);
|
||||
localStorage.setItem(`video-seen-${transcriptId}`, "1");
|
||||
}
|
||||
}, [videoNewBadge, transcriptId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!waiting || !transcript.data) return;
|
||||
|
||||
@@ -156,8 +172,14 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
<Grid
|
||||
templateColumns={{ base: "minmax(0, 1fr)", md: "repeat(2, 1fr)" }}
|
||||
templateRows={{
|
||||
base: "auto minmax(0, 1fr) minmax(0, 1fr)",
|
||||
md: "auto minmax(0, 1fr)",
|
||||
base:
|
||||
hasCloudVideo && videoExpanded
|
||||
? "auto auto minmax(0, 1fr) minmax(0, 1fr)"
|
||||
: "auto minmax(0, 1fr) minmax(0, 1fr)",
|
||||
md:
|
||||
hasCloudVideo && videoExpanded
|
||||
? "auto auto minmax(0, 1fr)"
|
||||
: "auto minmax(0, 1fr)",
|
||||
}}
|
||||
gap={4}
|
||||
gridRowGap={2}
|
||||
@@ -180,6 +202,10 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
transcript={transcript.data || null}
|
||||
topics={topics.topics}
|
||||
finalSummaryElement={finalSummaryElement}
|
||||
hasCloudVideo={hasCloudVideo}
|
||||
videoExpanded={videoExpanded}
|
||||
onVideoToggle={handleVideoToggle}
|
||||
videoNewBadge={videoNewBadge}
|
||||
/>
|
||||
</Flex>
|
||||
{mp3.audioDeleted && (
|
||||
@@ -190,6 +216,16 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
)}
|
||||
</Flex>
|
||||
</GridItem>
|
||||
{hasCloudVideo && videoExpanded && (
|
||||
<GridItem colSpan={{ base: 1, md: 2 }}>
|
||||
<VideoPlayer
|
||||
transcriptId={transcriptId}
|
||||
duration={transcript.data?.cloud_video_duration ?? null}
|
||||
expanded={videoExpanded}
|
||||
onClose={() => setVideoExpanded(false)}
|
||||
/>
|
||||
</GridItem>
|
||||
)}
|
||||
<TopicList
|
||||
topics={topics.topics || []}
|
||||
useActiveTopic={useActiveTopic}
|
||||
|
||||
@@ -10,11 +10,22 @@ import {
|
||||
useTranscriptUpdate,
|
||||
useTranscriptParticipants,
|
||||
} from "../../lib/apiHooks";
|
||||
import { Heading, IconButton, Input, Flex, Spacer } from "@chakra-ui/react";
|
||||
import { LuPen, LuCopy, LuCheck } from "react-icons/lu";
|
||||
import {
|
||||
Heading,
|
||||
IconButton,
|
||||
Input,
|
||||
Flex,
|
||||
Spacer,
|
||||
Spinner,
|
||||
Box,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
import { LuPen, LuCopy, LuCheck, LuDownload, LuVideo } from "react-icons/lu";
|
||||
import ShareAndPrivacy from "./shareAndPrivacy";
|
||||
import { buildTranscriptWithTopics } from "./buildTranscriptWithTopics";
|
||||
import { toaster } from "../../components/ui/toaster";
|
||||
import { useAuth } from "../../lib/AuthProvider";
|
||||
import { API_URL } from "../../lib/apiClient";
|
||||
|
||||
type TranscriptTitle = {
|
||||
title: string;
|
||||
@@ -25,13 +36,51 @@ type TranscriptTitle = {
|
||||
transcript: GetTranscriptWithParticipants | null;
|
||||
topics: GetTranscriptTopic[] | null;
|
||||
finalSummaryElement: HTMLDivElement | null;
|
||||
|
||||
// video props
|
||||
hasCloudVideo?: boolean;
|
||||
videoExpanded?: boolean;
|
||||
onVideoToggle?: () => void;
|
||||
videoNewBadge?: boolean;
|
||||
};
|
||||
|
||||
const TranscriptTitle = (props: TranscriptTitle) => {
|
||||
const [displayedTitle, setDisplayedTitle] = useState(props.title);
|
||||
const [preEditTitle, setPreEditTitle] = useState(props.title);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const updateTranscriptMutation = useTranscriptUpdate();
|
||||
const auth = useAuth();
|
||||
const accessToken = auth.status === "authenticated" ? auth.accessToken : null;
|
||||
const userId = auth.status === "authenticated" ? auth.user?.id : null;
|
||||
const isOwner = !!(userId && userId === props.transcript?.user_id);
|
||||
|
||||
const handleDownloadZip = async () => {
|
||||
if (!props.transcriptId || downloading) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (accessToken) {
|
||||
headers["Authorization"] = `Bearer ${accessToken}`;
|
||||
}
|
||||
const resp = await fetch(
|
||||
`${API_URL}/v1/transcripts/${props.transcriptId}/download/zip`,
|
||||
{ headers },
|
||||
);
|
||||
if (!resp.ok) throw new Error("Download failed");
|
||||
const blob = await resp.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `transcript_${props.transcriptId.split("-")[0]}.zip`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("Failed to download zip:", err);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
const participantsQuery = useTranscriptParticipants(
|
||||
props.transcript?.id ? parseMaybeNonEmptyString(props.transcript.id) : null,
|
||||
);
|
||||
@@ -173,6 +222,51 @@ const TranscriptTitle = (props: TranscriptTitle) => {
|
||||
>
|
||||
<LuCopy />
|
||||
</IconButton>
|
||||
{isOwner && (
|
||||
<IconButton
|
||||
aria-label="Download Transcript Zip"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
onClick={handleDownloadZip}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? <Spinner size="sm" /> : <LuDownload />}
|
||||
</IconButton>
|
||||
)}
|
||||
{props.hasCloudVideo && props.onVideoToggle && (
|
||||
<Box position="relative" display="inline-flex">
|
||||
<IconButton
|
||||
aria-label={
|
||||
props.videoExpanded
|
||||
? "Hide cloud recording"
|
||||
: "Show cloud recording"
|
||||
}
|
||||
size="sm"
|
||||
variant={props.videoExpanded ? "solid" : "subtle"}
|
||||
colorPalette={props.videoExpanded ? "blue" : undefined}
|
||||
onClick={props.onVideoToggle}
|
||||
>
|
||||
<LuVideo />
|
||||
</IconButton>
|
||||
{props.videoNewBadge && (
|
||||
<Text
|
||||
position="absolute"
|
||||
top="-1"
|
||||
right="-1"
|
||||
fontSize="2xs"
|
||||
fontWeight="bold"
|
||||
color="white"
|
||||
bg="red.500"
|
||||
px={1}
|
||||
borderRadius="sm"
|
||||
lineHeight="tall"
|
||||
pointerEvents="none"
|
||||
>
|
||||
new
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<ShareAndPrivacy
|
||||
finalSummaryElement={props.finalSummaryElement}
|
||||
transcript={props.transcript}
|
||||
|
||||
153
www/app/(app)/transcripts/videoPlayer.tsx
Normal file
153
www/app/(app)/transcripts/videoPlayer.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Box, Flex, Skeleton, Text } from "@chakra-ui/react";
|
||||
import { LuVideo, LuX } from "react-icons/lu";
|
||||
import { useAuth } from "../../lib/AuthProvider";
|
||||
import { API_URL } from "../../lib/apiClient";
|
||||
|
||||
type VideoPlayerProps = {
|
||||
transcriptId: string;
|
||||
duration: number | null;
|
||||
expanded: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h > 0)
|
||||
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default function VideoPlayer({
|
||||
transcriptId,
|
||||
duration,
|
||||
expanded,
|
||||
onClose,
|
||||
}: VideoPlayerProps) {
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const auth = useAuth();
|
||||
const accessToken = auth.status === "authenticated" ? auth.accessToken : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded || !transcriptId || videoUrl) return;
|
||||
|
||||
const fetchVideoUrl = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (accessToken) {
|
||||
params.set("token", accessToken);
|
||||
}
|
||||
const url = `${API_URL}/v1/transcripts/${transcriptId}/video/url?${params}`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (accessToken) {
|
||||
headers["Authorization"] = `Bearer ${accessToken}`;
|
||||
}
|
||||
const resp = await fetch(url, { headers });
|
||||
if (!resp.ok) {
|
||||
throw new Error("Failed to load video");
|
||||
}
|
||||
const data = await resp.json();
|
||||
setVideoUrl(data.url);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load video");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchVideoUrl();
|
||||
}, [expanded, transcriptId, accessToken, videoUrl]);
|
||||
|
||||
if (!expanded) return null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
borderRadius="md"
|
||||
overflow="hidden"
|
||||
bg="gray.900"
|
||||
w="fit-content"
|
||||
maxW="100%"
|
||||
>
|
||||
<Skeleton h="200px" w="400px" maxW="100%" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !videoUrl) {
|
||||
return (
|
||||
<Box
|
||||
p={3}
|
||||
bg="red.100"
|
||||
borderRadius="md"
|
||||
role="alert"
|
||||
w="fit-content"
|
||||
maxW="100%"
|
||||
>
|
||||
<Text fontSize="sm">Failed to load video recording</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderRadius="md" bg="black" w="fit-content" maxW="100%" mx="auto">
|
||||
{/* Header bar with title and close button */}
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
px={3}
|
||||
py={1.5}
|
||||
bg="gray.800"
|
||||
borderTopRadius="md"
|
||||
gap={4}
|
||||
>
|
||||
<Flex align="center" gap={2}>
|
||||
<LuVideo size={14} color="white" />
|
||||
<Text fontSize="xs" fontWeight="medium" color="white">
|
||||
Cloud recording
|
||||
</Text>
|
||||
{duration != null && (
|
||||
<Text fontSize="xs" color="gray.400">
|
||||
{formatDuration(duration)}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
borderRadius="full"
|
||||
p={1}
|
||||
cursor="pointer"
|
||||
onClick={onClose}
|
||||
_hover={{ bg: "whiteAlpha.300" }}
|
||||
transition="background 0.15s"
|
||||
>
|
||||
<LuX size={14} color="white" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
{/* Video element with visible controls */}
|
||||
<video
|
||||
src={videoUrl}
|
||||
controls
|
||||
autoPlay
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
maxWidth: "640px",
|
||||
maxHeight: "45vh",
|
||||
minHeight: "180px",
|
||||
objectFit: "contain",
|
||||
background: "black",
|
||||
borderBottomLeftRadius: "0.375rem",
|
||||
borderBottomRightRadius: "0.375rem",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user