Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";
import { Video } from "@/lib/model/Video";
import { corsFetch } from "@/lib/utils";
import { muxMediaToArrayBuffer } from "@/lib/ffmpeg";
import { useState, useRef, useEffect } from "react";
import { toast } from "sonner";
import { useMediaDownload } from "@/components/hooks/useMediaDownload";
Expand All @@ -20,6 +21,11 @@ import MyVideos from "@/components/video-list";
import MiniPlayer from "@/components/mini-player";
import Player from "@/components/player";

const sanitizeFileName = (name: string) => {
const sanitizedName = name.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_").trim();
return sanitizedName.length > 0 ? sanitizedName : "video";
};

export default function App() {
const [videos, setVideos] = useState<Video[]>([]);
const [currentView, setCurrentView] = useState("home");
Expand Down Expand Up @@ -179,6 +185,47 @@ export default function App() {
loadVideos();
};

const handleExportVideo = async (video: Video) => {
const media = await getMediaBinary(video.id);
if (!media) {
toast.error("Video is not downloaded yet");
return;
}

const toastId = toast.loading(video.title, {
description: "Preparing export...",
});

try {
const mergedMedia = await muxMediaToArrayBuffer(media.video, media.audio);
const exportBlob = new Blob([mergedMedia], { type: "video/mp4" });
const exportUrl = URL.createObjectURL(exportBlob);

const downloadLink = document.createElement("a");
downloadLink.href = exportUrl;
downloadLink.download = `${sanitizeFileName(video.title)}.mp4`;
downloadLink.style.display = "none";
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);

setTimeout(() => URL.revokeObjectURL(exportUrl), 1000);

toast.success(video.title, {
id: toastId,
description: "Export complete",
duration: 2000,
});
} catch (error) {
console.error(error);
toast.error(video.title, {
id: toastId,
description: "Export failed",
duration: 2000,
});
}
};

const handlePlayerBack = () => {
setCurrentView("home");
};
Expand Down Expand Up @@ -223,6 +270,7 @@ export default function App() {
<MyVideos
videos={videos}
onSelectVideo={handleSelectVideo}
onExportVideo={handleExportVideo}
onDeleteVideo={handleDeleteVideo}
/>
</div>
Expand Down
8 changes: 7 additions & 1 deletion components/video-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import VideoMenu from "./video-menu";
export default function MyVideos({
videos,
onSelectVideo,
onExportVideo,
onDeleteVideo,
}: {
videos: Video[];
onSelectVideo: (video: Video) => void;
onExportVideo: (video: Video) => void;
onDeleteVideo: (video: Video) => void;
}) {
return (
Expand Down Expand Up @@ -48,7 +50,11 @@ export default function MyVideos({
</p>
</div>
</div>
<VideoMenu video={video} onDeleteVideo={onDeleteVideo} />
<VideoMenu
video={video}
onExportVideo={onExportVideo}
onDeleteVideo={onDeleteVideo}
/>
</div>
))}
</div>
Expand Down
15 changes: 14 additions & 1 deletion components/video-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,19 @@ import {

interface VideoMenuProps {
video: Video;
onExportVideo: (video: Video) => void;
onDeleteVideo: (video: Video) => void;
}

export default function VideoMenu({ video, onDeleteVideo }: VideoMenuProps) {
export default function VideoMenu({
video,
onExportVideo,
onDeleteVideo,
}: VideoMenuProps) {
const handleExportVideo = () => {
onExportVideo(video);
};

const handleDeleteVideo = () => {
onDeleteVideo(video);
};
Expand Down Expand Up @@ -58,6 +67,10 @@ export default function VideoMenu({ video, onDeleteVideo }: VideoMenuProps) {
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem className="cursor-pointer" onClick={handleExportVideo}>
Export
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-500 focus:text-red-500 cursor-pointer"
onClick={handleDeleteVideo}
Expand Down
39 changes: 39 additions & 0 deletions lib/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,42 @@ export async function splitMediaToArrayBuffers(

return { video, audio };
}

export async function muxMediaToArrayBuffer(
video: ArrayBuffer,
audio: ArrayBuffer
): Promise<ArrayBuffer> {
const ff = await getFFmpeg();

const ts = Date.now();
const videoIn = `video_in_${ts}.mp4`;
const audioIn = `audio_in_${ts}.mp4`;
const output = `output_${ts}.mp4`;

await ff.writeFile(videoIn, new Uint8Array(video));
await ff.writeFile(audioIn, new Uint8Array(audio));

await ff.exec([
"-i",
videoIn,
"-i",
audioIn,
"-map",
"0:v:0",
"-map",
"1:a:0",
"-c",
"copy",
"-shortest",
output,
]);

const outputU8 = (await ff.readFile(output)) as Uint8Array;
const muxedMedia = new Uint8Array(outputU8).buffer;

await ff.deleteFile(videoIn);
await ff.deleteFile(audioIn);
await ff.deleteFile(output);

return muxedMedia;
}