diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..417f684 --- /dev/null +++ b/deploy.sh @@ -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 "$@" \ No newline at end of file diff --git a/src/app/favicon.ico b/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/src/app/favicon.ico and /dev/null differ diff --git a/src/app/page.tsx b/src/app/page.tsx index a932894..15fbff5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,103 +1,16 @@ -import Image from "next/image"; +function Link( + {href, label}: {href: string, label: string} +) { + return ( + {label} + ) +} export default function Home() { return ( -
-
- Next.js logo -
    -
  1. - Get started by editing{" "} - - src/app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
- -
- - Vercel logomark - Deploy now - - - Read our docs - -
-
- +
+ +
); } diff --git a/src/app/srt-player/components/AppCard.tsx b/src/app/srt-player/components/AppCard.tsx new file mode 100644 index 0000000..5e58f72 --- /dev/null +++ b/src/app/srt-player/components/AppCard.tsx @@ -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(null); + + const [videoUrl, setVideoUrl] = useState(null); + const [srtUrl, setSrtUrl] = useState(null); + + return ( +
+

SRT Video Player

+ + +
+ ); +} diff --git a/src/app/srt-player/components/Button.tsx b/src/app/srt-player/components/Button.tsx new file mode 100644 index 0000000..74f88fb --- /dev/null +++ b/src/app/srt-player/components/Button.tsx @@ -0,0 +1,7 @@ +export default function Button({ label, onClick }: { label: string, onClick?: () => void }) { + return ( + + ); +} \ No newline at end of file diff --git a/src/app/srt-player/components/UploadArea.tsx b/src/app/srt-player/components/UploadArea.tsx new file mode 100644 index 0000000..530f299 --- /dev/null +++ b/src/app/srt-player/components/UploadArea.tsx @@ -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(null); + + const [videoFile, setVideoFile] = useState(null); + const [SrtFile, setSrtFile] = useState(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 ( +
+ + + + + + + + + + + + + + + + + + + + + + + +
File NameTypeSizeActions
{videoFile?.name}Video{videoFile ? (videoFile.size / 1024 / 1024).toFixed(2) + 'MB' : null}
{SrtFile?.name}SRT{SrtFile ? (SrtFile.size / 1024 / 1024).toFixed(2) + 'MB' : null}
+ +
+ ) +} \ No newline at end of file diff --git a/src/app/srt-player/components/VideoPlayer/SubtitleDisplay.tsx b/src/app/srt-player/components/VideoPlayer/SubtitleDisplay.tsx new file mode 100644 index 0000000..3714ee6 --- /dev/null +++ b/src/app/srt-player/components/VideoPlayer/SubtitleDisplay.tsx @@ -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 ( +
+ { + words.map((v) => ( + + {v + ' '} + + )) + } +
+ ); +} \ No newline at end of file diff --git a/src/app/srt-player/components/VideoPlayer/VideoPanel.tsx b/src/app/srt-player/components/VideoPlayer/VideoPanel.tsx new file mode 100644 index 0000000..712b5b9 --- /dev/null +++ b/src/app/srt-player/components/VideoPlayer/VideoPanel.tsx @@ -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(( + { videoUrl, srtUrl }, videoRef +) => { + videoRef = videoRef as React.RefObject; + const [isPlaying, setIsPlaying] = useState(false); + const [srtLength, setSrtLength] = useState(0); + const [progress, setProgress] = useState(-1); + const [autoPause, setAutoPause] = useState(true); + const [spanText, setSpanText] = useState(''); + const [subtitle, setSubtitle] = useState(''); + const parsedSrtRef = useRef<{ start: number; end: number; text: string; }[] | null>(null); + const rafldRef = useRef(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) => { + 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) => { + 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 ( +
+ + +
+
    + + + + + +
+
+ + {spanText} +
+ ); +}); + +VideoPanel.displayName = 'VideoPanel'; + +export default VideoPanel; \ No newline at end of file diff --git a/src/app/srt-player/layout.tsx b/src/app/srt-player/layout.tsx new file mode 100644 index 0000000..81acea9 --- /dev/null +++ b/src/app/srt-player/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/src/app/srt-player/page.tsx b/src/app/srt-player/page.tsx new file mode 100644 index 0000000..34bca58 --- /dev/null +++ b/src/app/srt-player/page.tsx @@ -0,0 +1,9 @@ +import AppCard from "./components/AppCard"; + +export default function Home() { + return ( +
+ +
+ ); +} diff --git a/src/app/srt-player/subtitle.ts b/src/app/srt-player/subtitle.ts new file mode 100644 index 0000000..00f3f25 --- /dev/null +++ b/src/app/srt-player/subtitle.ts @@ -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)); +} diff --git a/src/app/word-board/Button.tsx b/src/app/word-board/Button.tsx new file mode 100644 index 0000000..74f88fb --- /dev/null +++ b/src/app/word-board/Button.tsx @@ -0,0 +1,7 @@ +export default function Button({ label, onClick }: { label: string, onClick?: () => void }) { + return ( + + ); +} \ No newline at end of file diff --git a/src/app/word-board/WordBoard.tsx b/src/app/word-board/WordBoard.tsx new file mode 100644 index 0000000..252558f --- /dev/null +++ b/src/app/word-board/WordBoard.tsx @@ -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 ( +
+ {words.map( + (v: { + word: string, + x: number, + y: number + }, i: number) => { + return ({v.word}) + })} +
+ ) +} \ No newline at end of file diff --git a/src/app/word-board/layout.tsx b/src/app/word-board/layout.tsx new file mode 100644 index 0000000..76225f4 --- /dev/null +++ b/src/app/word-board/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/src/app/word-board/page.tsx b/src/app/word-board/page.tsx new file mode 100644 index 0000000..c0773a8 --- /dev/null +++ b/src/app/word-board/page.tsx @@ -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(null); + const inputFileRef = useRef(null); + const initialWords = + [ + // 'apple', + // 'banana', + // 'cannon', + // 'desktop', + // 'kernel', + // 'system', + // 'programming', + // 'owe' + ] as Array; + 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 ( +
+ +
+ + + + + + +
+ +
+ ); +}