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:
Juan Diego García
2026-03-20 11:04:53 -05:00
committed by GitHub
parent cb1beae90d
commit a76f114378
21 changed files with 1413 additions and 77 deletions

View File

@@ -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}

View File

@@ -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}

View 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>
);
}

View File

@@ -90,8 +90,6 @@ export interface paths {
*
* Both cloud and raw-tracks are started via REST API to bypass enable_recording limitation of allowing only 1 recording at a time.
* Uses different instanceIds for cloud vs raw-tracks (same won't work)
*
* Note: No authentication required - anonymous users supported. TODO this is a DOS vector
*/
post: operations["v1_start_recording"];
delete?: never;
@@ -561,6 +559,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/transcripts/{transcript_id}/download/zip": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Transcript Download Zip */
get: operations["v1_transcript_download_zip"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/transcripts/{transcript_id}/video/url": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Transcript Get Video Url */
get: operations["v1_transcript_get_video_url"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/transcripts/{transcript_id}/events": {
parameters: {
query?: never;
@@ -785,6 +817,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/auth/login": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Login */
post: operations["v1_login"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -816,10 +865,7 @@ export interface components {
};
/** Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post */
Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post: {
/**
* Chunk
* Format: binary
*/
/** Chunk */
chunk: string;
};
/** CalendarEventResponse */
@@ -1034,6 +1080,13 @@ export interface components {
audio_deleted?: boolean | null;
/** Change Seq */
change_seq?: number | null;
/**
* Has Cloud Video
* @default false
*/
has_cloud_video: boolean;
/** Cloud Video Duration */
cloud_video_duration?: number | null;
};
/** GetTranscriptSegmentTopic */
GetTranscriptSegmentTopic: {
@@ -1182,6 +1235,13 @@ export interface components {
audio_deleted?: boolean | null;
/** Change Seq */
change_seq?: number | null;
/**
* Has Cloud Video
* @default false
*/
has_cloud_video: boolean;
/** Cloud Video Duration */
cloud_video_duration?: number | null;
/** Participants */
participants:
| components["schemas"]["TranscriptParticipantWithEmail"][]
@@ -1247,6 +1307,13 @@ export interface components {
audio_deleted?: boolean | null;
/** Change Seq */
change_seq?: number | null;
/**
* Has Cloud Video
* @default false
*/
has_cloud_video: boolean;
/** Cloud Video Duration */
cloud_video_duration?: number | null;
/** Participants */
participants:
| components["schemas"]["TranscriptParticipantWithEmail"][]
@@ -1313,6 +1380,13 @@ export interface components {
audio_deleted?: boolean | null;
/** Change Seq */
change_seq?: number | null;
/**
* Has Cloud Video
* @default false
*/
has_cloud_video: boolean;
/** Cloud Video Duration */
cloud_video_duration?: number | null;
/** Participants */
participants:
| components["schemas"]["TranscriptParticipantWithEmail"][]
@@ -1386,6 +1460,13 @@ export interface components {
audio_deleted?: boolean | null;
/** Change Seq */
change_seq?: number | null;
/**
* Has Cloud Video
* @default false
*/
has_cloud_video: boolean;
/** Cloud Video Duration */
cloud_video_duration?: number | null;
/** Participants */
participants:
| components["schemas"]["TranscriptParticipantWithEmail"][]
@@ -1461,6 +1542,13 @@ export interface components {
audio_deleted?: boolean | null;
/** Change Seq */
change_seq?: number | null;
/**
* Has Cloud Video
* @default false
*/
has_cloud_video: boolean;
/** Cloud Video Duration */
cloud_video_duration?: number | null;
/** Participants */
participants:
| components["schemas"]["TranscriptParticipantWithEmail"][]
@@ -1532,6 +1620,25 @@ export interface components {
/** Reason */
reason?: string | null;
};
/** LoginRequest */
LoginRequest: {
/** Email */
email: string;
/** Password */
password: string;
};
/** LoginResponse */
LoginResponse: {
/** Access Token */
access_token: string;
/**
* Token Type
* @default bearer
*/
token_type: string;
/** Expires In */
expires_in: number;
};
/** Meeting */
Meeting: {
/** Id */
@@ -1619,26 +1726,26 @@ export interface components {
/** Items */
items: components["schemas"]["GetTranscriptMinimal"][];
/** Total */
total?: number | null;
total: number;
/** Page */
page: number | null;
page: number;
/** Size */
size: number | null;
size: number;
/** Pages */
pages?: number | null;
pages: number;
};
/** Page[RoomDetails] */
Page_RoomDetails_: {
/** Items */
items: components["schemas"]["RoomDetails"][];
/** Total */
total?: number | null;
total: number;
/** Page */
page: number | null;
page: number;
/** Size */
size: number | null;
size: number;
/** Pages */
pages?: number | null;
pages: number;
};
/** Participant */
Participant: {
@@ -2269,6 +2376,22 @@ export interface components {
msg: string;
/** Error Type */
type: string;
/** Input */
input?: unknown;
/** Context */
ctx?: Record<string, never>;
};
/** VideoUrlResponse */
VideoUrlResponse: {
/** Url */
url: string;
/** Duration */
duration?: number | null;
/**
* Content Type
* @default video/mp4
*/
content_type: string;
};
/** WebhookTestResult */
WebhookTestResult: {
@@ -3682,6 +3805,70 @@ export interface operations {
};
};
};
v1_transcript_download_zip: {
parameters: {
query?: never;
header?: never;
path: {
transcript_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_transcript_get_video_url: {
parameters: {
query?: {
token?: string | null;
};
header?: never;
path: {
transcript_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["VideoUrlResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
v1_transcript_get_websocket_events: {
parameters: {
query?: never;
@@ -4021,4 +4208,37 @@ export interface operations {
};
};
};
v1_login: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["LoginRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LoginResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
}