2025-09-16
This commit is contained in:
208
deploy.sh
Normal file
208
deploy.sh
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Next.js 项目部署脚本
|
||||||
|
set -e # 遇到任何错误立即退出
|
||||||
|
|
||||||
|
# ===== 配置区域 =====
|
||||||
|
SERVER_IP="43.156.84.214"
|
||||||
|
SERVER_USER="ubuntu"
|
||||||
|
PROJECT_NAME="learn-languages"
|
||||||
|
LOCAL_PROJECT_DIR="/home/goddonebianu/Code/learn-languages"
|
||||||
|
REMOTE_PROJECT_DIR="/home/$SERVER_USER/$PROJECT_NAME"
|
||||||
|
BRANCH="main" # 要部署的分支
|
||||||
|
NODE_ENV="production"
|
||||||
|
PORT="3000" # 应用运行端口
|
||||||
|
# ===================
|
||||||
|
|
||||||
|
# 颜色输出函数
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo -e "${GREEN}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查本地更改
|
||||||
|
check_local_changes() {
|
||||||
|
log_info "检查本地更改..."
|
||||||
|
if ! git diff --quiet; then
|
||||||
|
log_warn "发现未提交的更改,建议先提交更改"
|
||||||
|
read -p "继续部署?(y/N): " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 构建项目
|
||||||
|
build_project() {
|
||||||
|
log_info "开始构建项目..."
|
||||||
|
cd "$LOCAL_PROJECT_DIR"
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
if ! npm ci --only=production; then
|
||||||
|
log_error "npm install 失败"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 构建项目
|
||||||
|
if ! npm run build; then
|
||||||
|
log_error "构建失败"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "项目构建成功"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 创建部署包
|
||||||
|
create_deployment_package() {
|
||||||
|
log_info "创建部署包..."
|
||||||
|
local temp_dir=$(mktemp -d)
|
||||||
|
local package_name="${PROJECT_NAME}-$(date +%Y%m%d-%H%M%S).tar.gz"
|
||||||
|
|
||||||
|
# 复制需要的文件
|
||||||
|
cp -r package.json package-lock.json next.config.js* .next public "$temp_dir/"
|
||||||
|
|
||||||
|
# 如果有环境文件也复制
|
||||||
|
if [ -f .env.production ]; then
|
||||||
|
cp .env.production "$temp_dir/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 创建压缩包
|
||||||
|
cd "$temp_dir"
|
||||||
|
tar -czf "/tmp/$package_name" .
|
||||||
|
|
||||||
|
echo "$package_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 部署到服务器
|
||||||
|
deploy_to_server() {
|
||||||
|
local package_name=$1
|
||||||
|
|
||||||
|
log_info "开始部署到服务器..."
|
||||||
|
|
||||||
|
# 上传部署包
|
||||||
|
if ! scp "/tmp/$package_name" $SERVER_USER@$SERVER_IP:/tmp/; then
|
||||||
|
log_error "文件上传失败"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 执行远程部署命令
|
||||||
|
ssh $SERVER_USER@$SERVER_IP << EOF
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "在服务器上执行部署..."
|
||||||
|
|
||||||
|
# 创建备份目录
|
||||||
|
BACKUP_DIR="$REMOTE_PROJECT_DIR-backup-\$(date +%Y%m%d-%H%M%S)"
|
||||||
|
if [ -d "$REMOTE_PROJECT_DIR" ]; then
|
||||||
|
mkdir -p "\$BACKUP_DIR"
|
||||||
|
cp -r "$REMOTE_PROJECT_DIR"/* "\$BACKUP_DIR/" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 创建项目目录
|
||||||
|
mkdir -p "$REMOTE_PROJECT_DIR"
|
||||||
|
|
||||||
|
# 解压新版本
|
||||||
|
tar -xzf "/tmp/$package_name" -C "$REMOTE_PROJECT_DIR"
|
||||||
|
|
||||||
|
# 清理临时文件
|
||||||
|
rm -f "/tmp/$package_name"
|
||||||
|
|
||||||
|
# 安装生产依赖
|
||||||
|
cd "$REMOTE_PROJECT_DIR"
|
||||||
|
npm ci --only=production
|
||||||
|
|
||||||
|
echo "部署文件准备完成"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log_info "文件部署完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 重启服务
|
||||||
|
restart_service() {
|
||||||
|
log_info "重启服务..."
|
||||||
|
|
||||||
|
ssh $SERVER_USER@$SERVER_IP << EOF
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$REMOTE_PROJECT_DIR"
|
||||||
|
|
||||||
|
# 使用 PM2 重启应用
|
||||||
|
if command -v pm2 &> /dev/null; then
|
||||||
|
# 如果应用已经在运行,重新加载
|
||||||
|
if pm2 list | grep -q "$PROJECT_NAME"; then
|
||||||
|
pm2 reload $PROJECT_NAME --update-env
|
||||||
|
else
|
||||||
|
# 第一次启动
|
||||||
|
export PORT=$PORT
|
||||||
|
pm2 start npm --name "$PROJECT_NAME" -- start
|
||||||
|
pm2 save
|
||||||
|
pm2 startup 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# 如果没有 PM2,直接启动(不推荐生产环境)
|
||||||
|
echo "警告: 未找到 PM2,直接启动进程"
|
||||||
|
export PORT=$PORT
|
||||||
|
npm start &
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "等待服务启动..."
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
# 检查服务是否正常运行
|
||||||
|
if curl -f http://localhost:$PORT > /dev/null 2>&1; then
|
||||||
|
echo "服务启动成功!"
|
||||||
|
else
|
||||||
|
echo "服务启动可能有问题,请检查日志"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log_info "服务重启完成"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 清理工作
|
||||||
|
cleanup() {
|
||||||
|
log_info "清理临时文件..."
|
||||||
|
rm -f "/tmp/${PROJECT_NAME}-*.tar.gz" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# 主部署流程
|
||||||
|
main() {
|
||||||
|
log_info "开始部署 $PROJECT_NAME 到 $SERVER_IP"
|
||||||
|
|
||||||
|
# 检查是否在项目目录
|
||||||
|
if [ ! -f "$LOCAL_PROJECT_DIR/package.json" ]; then
|
||||||
|
log_error "请在正确的 Next.js 项目目录中运行"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
check_local_changes
|
||||||
|
build_project
|
||||||
|
local package_name=$(create_deployment_package)
|
||||||
|
deploy_to_server "$package_name"
|
||||||
|
restart_service
|
||||||
|
cleanup
|
||||||
|
|
||||||
|
log_info "${GREEN}部署成功完成!${NC}"
|
||||||
|
log_info "应用地址: http://$SERVER_IP:$PORT"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 错误处理
|
||||||
|
trap 'log_error "部署过程中断"; cleanup; exit 1' INT TERM
|
||||||
|
trap 'cleanup' EXIT
|
||||||
|
|
||||||
|
# 执行主函数
|
||||||
|
main "$@"
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
107
src/app/page.tsx
107
src/app/page.tsx
@@ -1,103 +1,16 @@
|
|||||||
import Image from "next/image";
|
function Link(
|
||||||
|
{href, label}: {href: string, label: string}
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<a className="border-2 border-black m-1 p-5 rounded font-bold hover:bg-gray-200" href={href}>{label}</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
<div className="w-[500px] m-auto mt-[100px] h-[300px]">
|
||||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
<Link href="/srt-player" label="srt-player"></Link>
|
||||||
<Image
|
<Link href="/word-board" label="word-board"></Link>
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={180}
|
|
||||||
height={38}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
|
|
||||||
<li className="mb-2 tracking-[-.01em]">
|
|
||||||
Get started by editing{" "}
|
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
|
|
||||||
src/app/page.tsx
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li className="tracking-[-.01em]">
|
|
||||||
Save and see your changes instantly.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
/>
|
|
||||||
Deploy now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Read our docs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
25
src/app/srt-player/components/AppCard.tsx
Normal file
25
src/app/srt-player/components/AppCard.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import UploadArea from "./UploadArea";
|
||||||
|
import VideoPanel from "./VideoPlayer/VideoPanel";
|
||||||
|
|
||||||
|
export default function AppCard() {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||||
|
const [srtUrl, setSrtUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-w-[410px] max-h-[1000px] max-w-[1400px] flex items-center flex-col bg-gray-200 rounded shadow-2xl w-8/12 py-12">
|
||||||
|
<p className="text-4xl font-extrabold">SRT Video Player</p>
|
||||||
|
<VideoPanel
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
srtUrl={srtUrl}
|
||||||
|
ref={videoRef} />
|
||||||
|
<UploadArea
|
||||||
|
setVideoUrl={setVideoUrl}
|
||||||
|
setSrtUrl={setSrtUrl} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/app/srt-player/components/Button.tsx
Normal file
7
src/app/srt-player/components/Button.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default function Button({ label, onClick }: { label: string, onClick?: () => void }) {
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} className="m-1 px-2 py-1 rounded bg-white shadow-2xs font-bold hover:bg-gray-300">
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
src/app/srt-player/components/UploadArea.tsx
Normal file
75
src/app/srt-player/components/UploadArea.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import Button from "./Button";
|
||||||
|
|
||||||
|
export default function UploadArea(
|
||||||
|
{
|
||||||
|
setVideoUrl,
|
||||||
|
setSrtUrl
|
||||||
|
}: {
|
||||||
|
setVideoUrl: (url: string | null) => void;
|
||||||
|
setSrtUrl: (url: string | null) => void;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||||
|
const [SrtFile, setSrtFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const uploadVideo = () => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (input) {
|
||||||
|
input.setAttribute('accept', 'video/*');
|
||||||
|
input.click();
|
||||||
|
input.onchange = () => {
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setVideoFile(file);
|
||||||
|
setVideoUrl(URL.createObjectURL(file));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const uploadSRT = () => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (input) {
|
||||||
|
input.setAttribute('accept', '.srt');
|
||||||
|
input.click();
|
||||||
|
input.onchange = () => {
|
||||||
|
const file = input.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setSrtFile(file);
|
||||||
|
setSrtUrl(URL.createObjectURL(file));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<table className="border border-black border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="divide-x divide-black">
|
||||||
|
<th className="border border-black px-2 py-1">File Name</th>
|
||||||
|
<th className="border border-black px-2 py-1">Type</th>
|
||||||
|
<th className="border border-black px-2 py-1">Size</th>
|
||||||
|
<th className="border border-black px-2 py-1">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-black">
|
||||||
|
<tr className="divide-x divide-black">
|
||||||
|
<td className="px-2 py-1">{videoFile?.name}</td>
|
||||||
|
<td className="px-2 py-1">Video</td>
|
||||||
|
<td className="px-2 py-1">{videoFile ? (videoFile.size / 1024 / 1024).toFixed(2) + 'MB' : null}</td>
|
||||||
|
<td className="px-2 py-1"><Button label="Upload" onClick={uploadVideo} /></td>
|
||||||
|
</tr>
|
||||||
|
<tr className="divide-x divide-black">
|
||||||
|
<td className="px-2 py-1">{SrtFile?.name}</td>
|
||||||
|
<td className="px-2 py-1">SRT</td>
|
||||||
|
<td className="px-2 py-1">{SrtFile ? (SrtFile.size / 1024 / 1024).toFixed(2) + 'MB' : null}</td>
|
||||||
|
<td className="px-2 py-1"><Button label="Upload" onClick={uploadSRT} /></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<input type="file" className="hidden" ref={inputRef} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export default function SubtitleDisplay({ subtitle }: { subtitle: string }) {
|
||||||
|
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
|
||||||
|
const goto = (url: string) => {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
const inspect = (word: string) => {
|
||||||
|
return () => {
|
||||||
|
word = word.toLowerCase();
|
||||||
|
goto(`https://www.youdao.com/result?word=${word}&lang=en`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let i = 0;
|
||||||
|
return (
|
||||||
|
<div className="subtitle overflow-y-auto h-16 mt-2 break-words bg-black/50 font-sans text-white text-center text-2xl">
|
||||||
|
{
|
||||||
|
words.map((v) => (
|
||||||
|
<span
|
||||||
|
onClick={inspect(v)}
|
||||||
|
key={i++}
|
||||||
|
className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
{v + ' '}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
177
src/app/srt-player/components/VideoPlayer/VideoPanel.tsx
Normal file
177
src/app/srt-player/components/VideoPlayer/VideoPanel.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import { useState, useRef, forwardRef, useEffect, KeyboardEvent, useCallback } from "react";
|
||||||
|
import Button from "../Button";
|
||||||
|
import { getIndex, getNearistIndex, parseSrt } from "../../subtitle";
|
||||||
|
import SubtitleDisplay from "./SubtitleDisplay";
|
||||||
|
|
||||||
|
type VideoPanelProps = {
|
||||||
|
videoUrl: string | null;
|
||||||
|
srtUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>((
|
||||||
|
{ videoUrl, srtUrl }, videoRef
|
||||||
|
) => {
|
||||||
|
videoRef = videoRef as React.RefObject<HTMLVideoElement>;
|
||||||
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
|
const [srtLength, setSrtLength] = useState<number>(0);
|
||||||
|
const [progress, setProgress] = useState<number>(-1);
|
||||||
|
const [autoPause, setAutoPause] = useState<boolean>(true);
|
||||||
|
const [spanText, setSpanText] = useState<string>('');
|
||||||
|
const [subtitle, setSubtitle] = useState<string>('');
|
||||||
|
const parsedSrtRef = useRef<{ start: number; end: number; text: string; }[] | null>(null);
|
||||||
|
const rafldRef = useRef<number>(0);
|
||||||
|
const ready = useRef({
|
||||||
|
'vid': false,
|
||||||
|
'sub': false,
|
||||||
|
'all': function () { return this.vid && this.sub }
|
||||||
|
});
|
||||||
|
|
||||||
|
const togglePlayPause = useCallback(() => {
|
||||||
|
if (!videoUrl) return;
|
||||||
|
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
if (video.paused || video.currentTime === 0) {
|
||||||
|
video.play();
|
||||||
|
} else {
|
||||||
|
video.pause();
|
||||||
|
}
|
||||||
|
setIsPlaying(!video.paused);
|
||||||
|
}, [videoRef, videoUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const cb = () => {
|
||||||
|
if (ready.current.all()) {
|
||||||
|
if (!parsedSrtRef.current) {
|
||||||
|
;
|
||||||
|
} else if (isPlaying) {
|
||||||
|
// 这里负责显示当前时间的字幕与自动暂停
|
||||||
|
const srt = parsedSrtRef.current;
|
||||||
|
const ct = videoRef.current?.currentTime as number;
|
||||||
|
const index = getIndex(srt, ct);
|
||||||
|
if (index !== null) {
|
||||||
|
setSubtitle(srt[index].text)
|
||||||
|
if (autoPause && ct >= (srt[index].end - 0.05) && ct < srt[index].end) {
|
||||||
|
videoRef.current!.currentTime = srt[index].start;
|
||||||
|
togglePlayPause();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSubtitle('');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rafldRef.current = requestAnimationFrame(cb);
|
||||||
|
}
|
||||||
|
rafldRef.current = requestAnimationFrame(cb);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(rafldRef.current);
|
||||||
|
}
|
||||||
|
}, [autoPause, isPlaying, togglePlayPause, videoRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (videoUrl && videoRef.current) {
|
||||||
|
videoRef.current.src = videoUrl;
|
||||||
|
videoRef.current.load();
|
||||||
|
setIsPlaying(false);
|
||||||
|
ready.current['vid'] = true;
|
||||||
|
}
|
||||||
|
}, [videoRef, videoUrl]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (srtUrl) {
|
||||||
|
fetch(srtUrl)
|
||||||
|
.then(response => response.text())
|
||||||
|
.then(data => {
|
||||||
|
parsedSrtRef.current = parseSrt(data);
|
||||||
|
setSrtLength(parsedSrtRef.current.length);
|
||||||
|
ready.current['sub'] = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [srtUrl]);
|
||||||
|
|
||||||
|
const timeUpdate = () => {
|
||||||
|
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||||
|
const index = getIndex(parsedSrtRef.current, videoRef.current.currentTime);
|
||||||
|
if (!index) return;
|
||||||
|
setSpanText(`${index + 1}/${parsedSrtRef.current.length}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (videoRef.current && parsedSrtRef.current) {
|
||||||
|
const newProgress = parseInt(e.target.value);
|
||||||
|
videoRef.current.currentTime = parsedSrtRef.current[newProgress]?.start || 0;
|
||||||
|
setProgress(newProgress);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAutoPauseToggle = () => {
|
||||||
|
setAutoPause(!autoPause);
|
||||||
|
};
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||||
|
const i = getNearistIndex(parsedSrtRef.current, videoRef.current.currentTime);
|
||||||
|
if (i != null && i + 1 < parsedSrtRef.current.length) {
|
||||||
|
videoRef.current.currentTime = parsedSrtRef.current[i + 1].start;
|
||||||
|
videoRef.current.play();
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = () => {
|
||||||
|
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||||
|
const i = getNearistIndex(parsedSrtRef.current, videoRef.current.currentTime);
|
||||||
|
if (i != null && i - 1 >= 0) {
|
||||||
|
videoRef.current.currentTime = parsedSrtRef.current[i - 1].start;
|
||||||
|
videoRef.current.play();
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const restart = () => {
|
||||||
|
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||||
|
const i = getNearistIndex(parsedSrtRef.current, videoRef.current.currentTime);
|
||||||
|
if (i != null && i >= 0) {
|
||||||
|
videoRef.current.currentTime = parsedSrtRef.current[i].start;
|
||||||
|
videoRef.current.play();
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDownEvent = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (e.key === 'n') {
|
||||||
|
next();
|
||||||
|
} else if (e.key === 'p') {
|
||||||
|
previous();
|
||||||
|
} else if (e.key === ' ') {
|
||||||
|
togglePlayPause();
|
||||||
|
} else if (e.key === 'r') {
|
||||||
|
restart();
|
||||||
|
} else if (e.key === 'a') {
|
||||||
|
; handleAutoPauseToggle();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-9/12" onKeyDown={handleKeyDownEvent}>
|
||||||
|
<video className="max-h-80" ref={videoRef} onTimeUpdate={timeUpdate}></video>
|
||||||
|
<SubtitleDisplay subtitle={subtitle}></SubtitleDisplay>
|
||||||
|
<div className="buttons">
|
||||||
|
<ul className="">
|
||||||
|
<Button label={isPlaying ? 'PAUSE' : 'PLAY'} onClick={togglePlayPause}></Button>
|
||||||
|
<Button label="NEXT" onClick={next}></Button>
|
||||||
|
<Button label="PREVIOUS" onClick={previous}></Button>
|
||||||
|
<Button label="RESTART" onClick={restart}></Button>
|
||||||
|
<Button label={`AUTOPAUSE(${autoPause ? 'Y' : 'N'})`} onClick={handleAutoPauseToggle}></Button>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<input className="seekbar" type="range" min={0} max={srtLength} onChange={handleSeek} step={1} value={progress}></input>
|
||||||
|
<span>{spanText}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
VideoPanel.displayName = 'VideoPanel';
|
||||||
|
|
||||||
|
export default VideoPanel;
|
||||||
34
src/app/srt-player/layout.tsx
Normal file
34
src/app/srt-player/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "SRT Video Player",
|
||||||
|
description: "Practice spoken English",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/app/srt-player/page.tsx
Normal file
9
src/app/srt-player/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import AppCard from "./components/AppCard";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="flex w-screen h-screen items-center justify-center">
|
||||||
|
<AppCard />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/app/srt-player/subtitle.ts
Normal file
52
src/app/srt-player/subtitle.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
export function parseSrt(data: string) {
|
||||||
|
const lines = data.split(/\r?\n/);
|
||||||
|
const result = [];
|
||||||
|
const re = new RegExp('(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})');
|
||||||
|
let i = 0;
|
||||||
|
while (i < lines.length) {
|
||||||
|
if (!lines[i].trim()) { i++; continue; }
|
||||||
|
i++;
|
||||||
|
if (i >= lines.length) break;
|
||||||
|
const timeMatch = lines[i].match(re);
|
||||||
|
if (!timeMatch) { i++; continue; }
|
||||||
|
const start = toSeconds(timeMatch[1]);
|
||||||
|
const end = toSeconds(timeMatch[2]);
|
||||||
|
i++;
|
||||||
|
let text = '';
|
||||||
|
while (i < lines.length && lines[i].trim()) {
|
||||||
|
text += lines[i] + '\n';
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
result.push({ start, end, text: text.trim() });
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNearistIndex(srt: { start: number; end: number; text: string; }[], ct: number) {
|
||||||
|
for (let i = 0; i < srt.length; i++) {
|
||||||
|
const s = srt[i];
|
||||||
|
const l = ct - s.start >= 0;
|
||||||
|
const r = ct - s.end >= 0;
|
||||||
|
if (!(l || r)) return i - 1;
|
||||||
|
if (l && (!r)) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIndex(srt: { start: number; end: number; text: string; }[], ct: number) {
|
||||||
|
for (let i = 0; i < srt.length; i++) {
|
||||||
|
if (ct >= srt[i].start && ct <= srt[i].end) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSubtitle(srt: { start: number; end: number; text: string; }[], currentTime: number) {
|
||||||
|
return srt.find(sub => currentTime >= sub.start && currentTime <= sub.end) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSeconds(timeStr: string): number {
|
||||||
|
const [h, m, s] = timeStr.replace(',', '.').split(':');
|
||||||
|
return parseFloat((parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s)).toFixed(3));
|
||||||
|
}
|
||||||
7
src/app/word-board/Button.tsx
Normal file
7
src/app/word-board/Button.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export default function Button({ label, onClick }: { label: string, onClick?: () => void }) {
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} className="m-1 px-2 py-1 rounded bg-white shadow-2xs font-bold hover:bg-gray-300">
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/app/word-board/WordBoard.tsx
Normal file
42
src/app/word-board/WordBoard.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
export default function WordBoard(
|
||||||
|
{ words }: {
|
||||||
|
words: [
|
||||||
|
{
|
||||||
|
word: string,
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const inspect = (word: string) => {
|
||||||
|
const goto = (url: string) => {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
word = word.toLowerCase();
|
||||||
|
goto(`https://www.youdao.com/result?word=${word}&lang=en`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="relative rounded bg-white w-[1000px] h-[600px]">
|
||||||
|
{words.map(
|
||||||
|
(v: {
|
||||||
|
word: string,
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
}, i: number) => {
|
||||||
|
return (<span
|
||||||
|
style={{
|
||||||
|
left: `${Math.floor(v.x * (1000 - 18 * v.word.length))}px`,
|
||||||
|
top: `${Math.floor(v.y * (600 - 30))}px`,
|
||||||
|
}}
|
||||||
|
className={`select-none cursor-pointer absolute font-mono text-[30px] border-amber-100 border-1`}
|
||||||
|
key={i}
|
||||||
|
onClick={inspect(v.word)}>{v.word}</span>)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
src/app/word-board/layout.tsx
Normal file
34
src/app/word-board/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "../globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Word Board",
|
||||||
|
description: "Word board page",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/app/word-board/page.tsx
Normal file
94
src/app/word-board/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
import WordBoard from "@/app/word-board/WordBoard";
|
||||||
|
import Button from "./Button";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
|
interface Word {
|
||||||
|
word: string,
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const inputFileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const initialWords =
|
||||||
|
[
|
||||||
|
// 'apple',
|
||||||
|
// 'banana',
|
||||||
|
// 'cannon',
|
||||||
|
// 'desktop',
|
||||||
|
// 'kernel',
|
||||||
|
// 'system',
|
||||||
|
// 'programming',
|
||||||
|
// 'owe'
|
||||||
|
] as Array<string>;
|
||||||
|
const [words, setWords] = useState(
|
||||||
|
initialWords.map((v: string) => ({
|
||||||
|
'word': v,
|
||||||
|
'x': Math.random(),
|
||||||
|
'y': Math.random()
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const generateNewWord = (word: string) => {
|
||||||
|
return {
|
||||||
|
word: word,
|
||||||
|
x: Math.random(),
|
||||||
|
y: Math.random()
|
||||||
|
} as Word;
|
||||||
|
}
|
||||||
|
const insertWord = () => {
|
||||||
|
if (!inputRef.current) return;
|
||||||
|
const word = inputRef.current.value.trim();
|
||||||
|
if (word === '') return;
|
||||||
|
setWords([...words, generateNewWord(word)]);
|
||||||
|
inputRef.current.value = '';
|
||||||
|
}
|
||||||
|
const deleteWord = () => {
|
||||||
|
if (!inputRef.current) return;
|
||||||
|
const word = inputRef.current.value.trim();
|
||||||
|
if (word === '') return;
|
||||||
|
setWords(words.filter((v) => v.word !== word));
|
||||||
|
inputRef.current.value = '';
|
||||||
|
};
|
||||||
|
const importWords = () => {
|
||||||
|
inputFileRef.current?.click();
|
||||||
|
}
|
||||||
|
const exportWords = () => {
|
||||||
|
const blob = new Blob([JSON.stringify(words)], {
|
||||||
|
type: 'application/json'
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${Date.now()}.json`;
|
||||||
|
a.style.display = 'none';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
const handleFileChange = () => {
|
||||||
|
const files = inputFileRef.current?.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
if (reader.result && typeof reader.result === 'string')
|
||||||
|
setWords(JSON.parse(reader.result) as [Word]);
|
||||||
|
}
|
||||||
|
reader.readAsText(files[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="p-5 my-10 mx-auto bg-gray-200 rounded shadow-2xl w-[1050px]">
|
||||||
|
<WordBoard words={words as [Word]} />
|
||||||
|
<div className="flex justify-center rounded mt-3 w-[1000px]">
|
||||||
|
<input ref={inputRef} placeholder="在此插入/删除单词" type="text" className="focus:outline-none border-b-2 border-black" />
|
||||||
|
<Button label="插入" onClick={insertWord}></Button>
|
||||||
|
<Button label="删除" onClick={deleteWord}></Button>
|
||||||
|
<Button label="导入" onClick={importWords}></Button>
|
||||||
|
<Button label="导出" onClick={exportWords}></Button>
|
||||||
|
<Button label="删光" onClick={()=>{setWords([] as Array<Word>)}}></Button>
|
||||||
|
</div>
|
||||||
|
<input type="file" ref={inputFileRef} className="hidden" accept="application/json" onChange={handleFileChange}></input>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user