Skip to content

Commit

Permalink
feat: Complete VideoViewer
Browse files Browse the repository at this point in the history
  • Loading branch information
NriotHrreion committed Aug 3, 2024
1 parent 0f611cf commit 3e3629a
Show file tree
Hide file tree
Showing 4 changed files with 158 additions and 30 deletions.
5 changes: 4 additions & 1 deletion app/api/fs/file/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from "node:fs";
import path from "node:path";

import { NextRequest, NextResponse } from "next/server";
import mime from "mime";
Expand All @@ -7,6 +8,7 @@ import { tokenStorageKey } from "@/lib/global";
import { validateToken } from "@/lib/token";
import { packet, error } from "@/lib/packet";
import { streamFile } from "@/lib/stream";
import { getFileType } from "@/lib/utils";

export async function GET(req: NextRequest) {
const token = req.cookies.get(tokenStorageKey)?.value;
Expand All @@ -17,13 +19,14 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const targetPath = searchParams.get("path") ?? "/";
const isStream = (searchParams.get("stream") ?? "0") === "1"; // 0 = false, 1 = true
const fileType = getFileType(path.extname(targetPath).replace(".", ""))?.id;

try {
if(!targetPath || !fs.existsSync(targetPath)) return error(404);

const stat = fs.statSync(targetPath);

if(!stat.isFile()) return error(400);
if(!stat.isFile() || ((fileType === "audio" || fileType === "video") && !isStream)) return error(400);

if(isStream) {
const stream = fs.createReadStream(targetPath);
Expand Down
21 changes: 13 additions & 8 deletions components/player-progress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import React, { useEffect, useState } from "react";
import { Progress } from "@nextui-org/progress";
import { cn } from "@nextui-org/theme";

import { emitter } from "@/lib/emitter";
import { getCurrentState } from "@/lib/utils";

function secondToTime(second: number): string {
Expand All @@ -18,11 +17,15 @@ function secondToTime(second: number): string {
interface PlayerProgressProps {
duration: number
current: number
timePlacement: "top" | "bottom"
disabled?: boolean
onTimeChange?: (time: number) => void
}

const PlayerProgress: React.FC<PlayerProgressProps> = (props) => {
const currentTime = secondToTime(props.current);
const duration = secondToTime(props.duration);
const disabled = props.disabled === undefined ? false : props.disabled;
const percent = props.current / props.duration;

const [isDragging, setIsDragging] = useState<boolean>(false);
Expand All @@ -33,10 +36,10 @@ const PlayerProgress: React.FC<PlayerProgressProps> = (props) => {

if(!progressBar) return;

var x = e.clientX - progressBar.offsetLeft;
var x = e.clientX - progressBar.getBoundingClientRect().left;
var result = x / progressBar.clientWidth * 100;

emitter.emit("viewer:audio-player-time-change", result / 100 * props.duration);
if(props.onTimeChange) props.onTimeChange(result / 100 * props.duration);
};

useEffect(() => {
Expand All @@ -49,7 +52,7 @@ const PlayerProgress: React.FC<PlayerProgressProps> = (props) => {

if(!progressBar) return;

var x = e.clientX - progressBar.offsetLeft;
var x = e.clientX - progressBar.getBoundingClientRect().left;
var result = x / progressBar.clientWidth * 100;

if(result < 0) {
Expand All @@ -71,21 +74,21 @@ const PlayerProgress: React.FC<PlayerProgressProps> = (props) => {
if(!(await getCurrentState(setIsDragging))) return;

setIsDragging(false);
emitter.emit("viewer:audio-player-time-change", draggingValue / 100 * props.duration);
if(props.onTimeChange) props.onTimeChange(draggingValue / 100 * props.duration);
setDraggingValue(-1);
}, { signal: controller.signal });

return () => controller.abort();
}, [draggingValue]);

return (
<div className="flex flex-col gap-1">
<div className={cn("flex flex-col gap-1", props.timePlacement === "bottom" ? "flex-col-reverse" : "")}>
<div className="flex justify-between *:text-sm *:text-default-400">
<span>{currentTime}</span>
<span>{duration}</span>
</div>

<div className="relative h-3 flex items-center" id="progress-bar">
<div className="relative h-3 mt-[-0.25rem] flex items-center" id="progress-bar">
<Progress
classNames={{
indicator: "bg-default-800"
Expand All @@ -104,6 +107,8 @@ const PlayerProgress: React.FC<PlayerProgressProps> = (props) => {
role="slider"
className="w-2 h-2 hover:w-3 hover:h-3 transition-all mt-[0.125rem] hover:mt-0 bg-default-800 rounded-full"
onMouseDown={(e) => {
if(disabled) return;

setIsDragging(true);
setDraggingValue(percent * 100);

Expand All @@ -113,7 +118,7 @@ const PlayerProgress: React.FC<PlayerProgressProps> = (props) => {
tabIndex={-1}
aria-valuenow={percent}/>

<div className={cn((isDragging ? "block" : "hidden"), "absolute top-3")}>
<div className={cn((isDragging ? "block" : "hidden"), "absolute", props.timePlacement === "bottom" ? "top-[-1.5rem]" : "top-3")}>
<span className="font-semibold">{secondToTime(draggingValue / 100 * props.duration)}</span>
</div>
</div>
Expand Down
20 changes: 12 additions & 8 deletions components/viewers/audio-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ export default class AudioViewer extends Viewer<AudioViewerProps, AudioViewerSta
}
}

private handleTimeChange(time: number) {
if(!this.audioRef.current) return;

this.audioRef.current.currentTime = time;
this.setState({ currentTime: time });
}

public render(): React.ReactNode {
return (
<div className="w-full h-full flex justify-center pt-44">
Expand Down Expand Up @@ -135,9 +142,13 @@ export default class AudioViewer extends Viewer<AudioViewerProps, AudioViewerSta
ref={this.audioRef}>
<track kind="captions"/>
</audio>

<PlayerProgress
duration={this.state.duration}
current={this.state.currentTime}/>
current={this.state.currentTime}
timePlacement="top"
disabled={this.state.isLoading}
onTimeChange={(time) => this.handleTimeChange(time)}/>
</div>
</div>
</div>
Expand Down Expand Up @@ -180,12 +191,5 @@ export default class AudioViewer extends Viewer<AudioViewerProps, AudioViewerSta
e.stopImmediatePropagation();
}
}, { signal: this.eventController.signal });

emitter.on("viewer:audio-player-time-change", (time: number) => {
if(!this.audioRef.current) return;

this.audioRef.current.currentTime = time;
this.setState({ currentTime: time });
});
}
}
142 changes: 129 additions & 13 deletions components/viewers/video-viewer.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,164 @@
"use client";

import React from "react";
import { ReactSVG } from "react-svg";
import { Maximize } from "lucide-react";
import { cn } from "@nextui-org/theme";

import PlayerProgress from "../player-progress";

import Viewer, { ViewerProps } from ".";

import PlayIcon from "@/styles/icons/play.svg";
import PauseIcon from "@/styles/icons/pause.svg";
import { emitter } from "@/lib/emitter";
import { CircularProgress } from "@nextui-org/progress";

interface VideoViewerProps extends ViewerProps {}

interface VideoViewerState {
isLoading: boolean
value: string

paused: boolean
duration: number
currentTime: number
}

export default class VideoViewer extends Viewer<VideoViewerProps, VideoViewerState> {
private readonly isFlv: boolean;
private blob: Blob = new Blob();
private videoRef = React.createRef<HTMLVideoElement>();

private readonly eventController = new AbortController();

public constructor(props: VideoViewerProps) {
super(props, "视频播放器");

this.state = {
value: "" // video url
isLoading: true,
value: "", // video url
paused: true,
duration: 0,
currentTime: 0
};

this.isFlv = this.props.fileName.split(".").findLast(() => true) === "flv";
}

private handlePlayButtonClick() {
if(!this.videoRef.current || this.state.isLoading) {
this.setState({ paused: true });

return;
}

if(this.videoRef.current.paused) {
this.setState({ paused: false });
this.videoRef.current.play();
} else {
this.setState({ paused: true });
this.videoRef.current.pause();
}
}

private handleFullScreen() {
if(!this.videoRef.current) return;

this.videoRef.current.requestFullscreen();
}

private handleTimeChange(time: number) {
if(!this.videoRef.current) return;

this.videoRef.current.currentTime = time;
this.setState({ currentTime: time });
}

public render(): React.ReactNode {
return (
<div className="w-full h-full flex flex-col items-center">
<video
className="max-h-[700px]"
src={this.state.value}
controls>
<track kind="captions"/>
</video>

<div className="w-full h-6 mt-2 px-2 flex justify-center items-center">
<span className="text-default-400 text-sm ml-2">{this.props.fileName}</span>
<div className="w-full h-full flex justify-center items-center">
<div className="flex flex-col relative">
<video
src={this.state.value}
className={cn("rounded-t-lg relative", this.state.isLoading ? "bg-default-300" : "")}
onDurationChange={(e) => this.setState({ duration: e.currentTarget.duration })}
onTimeUpdate={(e) => this.setState({ currentTime: e.currentTarget.currentTime })}
onEnded={() => this.setState({ paused: true })}
ref={this.videoRef}>
<track kind="captions"/>
</video>

{this.state.isLoading && (
<div className="absolute top-0 left-0 right-0 bottom-0 flex justify-center items-center">
<CircularProgress
size="sm"
classNames={{
indicator: "stroke-default-800"
}}/>
</div>
)}

<div className="absolute left-0 right-0 bottom-8 h-10 p-2 flex justify-between">
<div className="flex items-center">
<button
onClick={() => this.handlePlayButtonClick()}
disabled={this.state.isLoading}>
<ReactSVG
src={this.state.paused ? PlayIcon["src"] : PauseIcon["src"]}
className="w-6 h-6 [&_svg]:w-full [&_svg]:h-full"/>
</button>
</div>

<div className="flex items-center">
<button
onClick={() => this.handleFullScreen()}
disabled={this.state.isLoading}>
<Maximize size={22} color="#e8eaed" className="pr-1"/>
</button>
</div>
</div>

<PlayerProgress
duration={this.state.duration}
current={this.state.currentTime}
timePlacement="bottom"
disabled={this.state.isLoading}
onTimeChange={(time) => this.handleTimeChange(time)}/>
</div>
</div>
);
}

public async componentDidMount() {
/** @todo */
// this.setState({ value: URL.createObjectURL(new Blob([Buffer.from(await this.loadFile())])) });
const data = await this.loadFile(true) as ArrayBuffer;

this.blob = new Blob([Buffer.from(data)], { type: "video/mp4" });
this.setState({ isLoading: false });

this.setState({ value: URL.createObjectURL(this.blob) });
this.initEvents();
}

public componentWillUnmount() {
if(!this.blob) return;

URL.revokeObjectURL(this.state.value);
this.blob = new Blob();
this.setState({ value: "" });

this.eventController.abort();
emitter.removeAllListeners("viewer:audio-player-time-change");
}

private initEvents() {
document.body.addEventListener("keydown", (e) => {
if(e.code === "Space") {
this.handlePlayButtonClick();

/** @see https://www.codeproject.com/Questions/1044876/one-enter-keypress-runs-event-handler-twice */
e.stopImmediatePropagation();
}
}, { signal: this.eventController.signal });
}
}

0 comments on commit 3e3629a

Please sign in to comment.