2
0
Просмотр исходного кода

✨ feat(mobile): 添加视频播放功能

- 新增VideoPlayerPage组件,实现视频播放功能
- 实现视频播放控制(播放/暂停、音量调节、进度条、全屏)
- 添加视频加载状态和错误处理
- 视频列表页点击视频跳转到播放页
- 配置视频播放页面路由

📝 docs(video): 更新视频相关类型定义

- 定义VodVideoResponse类型,规范视频数据结构

🔧 chore(routes): 添加视频播放页面路由配置

- 在mobile路由中添加/video/:id路径指向视频播放页面
yourname 6 месяцев назад
Родитель
Сommit
cc6a1ebf90

+ 257 - 0
src/client/mobile/pages/VideoPlayerPage.tsx

@@ -0,0 +1,257 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { useParams, useNavigate } from 'react-router';
+import { ArrowLeft, Play, Pause, Volume2, VolumeX, Maximize, Settings } from 'lucide-react';
+import { vodVideoClient } from '@/client/api';
+import type { InferResponseType } from 'hono/client';
+
+// 定义视频响应类型
+type VodVideoResponse = InferResponseType<typeof vodVideoClient.$get, 200>['data'][0];
+
+const VideoPlayerPage: React.FC = () => {
+  const { id } = useParams<{ id: string }>();
+  const navigate = useNavigate();
+  const videoRef = useRef<HTMLVideoElement>(null);
+  
+  const [video, setVideo] = useState<VodVideoResponse | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string>('');
+  const [isPlaying, setIsPlaying] = useState(false);
+  const [isMuted, setIsMuted] = useState(false);
+  const [currentTime, setCurrentTime] = useState(0);
+  const [duration, setDuration] = useState(0);
+  const [showControls, setShowControls] = useState(true);
+
+  useEffect(() => {
+    if (id) {
+      fetchVideo();
+    }
+  }, [id]);
+
+  const fetchVideo = async () => {
+    try {
+      setLoading(true);
+      const response = await vodVideoClient[':id'].$get({
+        param: { id }
+      });
+
+      if (response.status === 200) {
+        const data = await response.json();
+        setVideo(data);
+      } else {
+        setError('获取视频信息失败');
+      }
+    } catch (err) {
+      console.error('获取视频信息错误:', err);
+      setError('网络请求失败');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const togglePlay = () => {
+    if (videoRef.current) {
+      if (isPlaying) {
+        videoRef.current.pause();
+      } else {
+        videoRef.current.play();
+      }
+      setIsPlaying(!isPlaying);
+    }
+  };
+
+  const toggleMute = () => {
+    if (videoRef.current) {
+      videoRef.current.muted = !isMuted;
+      setIsMuted(!isMuted);
+    }
+  };
+
+  const handleTimeUpdate = () => {
+    if (videoRef.current) {
+      setCurrentTime(videoRef.current.currentTime);
+    }
+  };
+
+  const handleLoadedMetadata = () => {
+    if (videoRef.current) {
+      setDuration(videoRef.current.duration);
+    }
+  };
+
+  const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
+    if (videoRef.current && duration > 0) {
+      const progressBar = e.currentTarget;
+      const rect = progressBar.getBoundingClientRect();
+      const percent = (e.clientX - rect.left) / rect.width;
+      const newTime = percent * duration;
+      
+      videoRef.current.currentTime = newTime;
+      setCurrentTime(newTime);
+    }
+  };
+
+  const formatTime = (time: number): string => {
+    const minutes = Math.floor(time / 60);
+    const seconds = Math.floor(time % 60);
+    return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
+  };
+
+  const handleFullscreen = () => {
+    if (videoRef.current) {
+      if (videoRef.current.requestFullscreen) {
+        videoRef.current.requestFullscreen();
+      }
+    }
+  };
+
+  if (loading) {
+    return (
+      <div className="min-h-screen bg-black flex items-center justify-center">
+        <div className="text-white">加载中...</div>
+      </div>
+    );
+  }
+
+  if (error || !video) {
+    return (
+      <div className="min-h-screen bg-black flex items-center justify-center">
+        <div className="text-white text-center">
+          <p>{error || '视频不存在'}</p>
+          <button
+            onClick={() => navigate('/mobile/video-replay')}
+            className="mt-4 bg-primary text-primary-foreground px-4 py-2 rounded-md"
+          >
+            返回视频列表
+          </button>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="min-h-screen bg-black">
+      {/* 顶部导航栏 */}
+      <div className="fixed top-0 left-0 right-0 z-50 bg-black/50 backdrop-blur-sm p-4">
+        <div className="flex items-center">
+          <button
+            onClick={() => navigate('/mobile/video-replay')}
+            className="text-white p-2 rounded-full hover:bg-white/10"
+          >
+            <ArrowLeft className="w-6 h-6" />
+          </button>
+          <h1 className="text-white text-sm font-medium ml-4 line-clamp-1 flex-1">
+            {video.title}
+          </h1>
+        </div>
+      </div>
+
+      {/* 视频播放器 */}
+      <div 
+        className="w-full h-screen flex items-center justify-center bg-black"
+        onClick={() => setShowControls(!showControls)}
+      >
+        <video
+          ref={videoRef}
+          className="w-full h-full object-contain"
+          src={video.videoUrl}
+          onTimeUpdate={handleTimeUpdate}
+          onLoadedMetadata={handleLoadedMetadata}
+          onPlay={() => setIsPlaying(true)}
+          onPause={() => setIsPlaying(false)}
+          onEnded={() => setIsPlaying(false)}
+          autoPlay
+          playsInline
+        />
+
+        {/* 视频控制栏 */}
+        {showControls && (
+          <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
+            {/* 进度条 */}
+            <div 
+              className="w-full h-1 bg-white/30 rounded-full mb-4 cursor-pointer"
+              onClick={handleSeek}
+            >
+              <div 
+                className="h-full bg-primary rounded-full"
+                style={{ width: `${(currentTime / duration) * 100}%` }}
+              />
+            </div>
+
+            {/* 控制按钮 */}
+            <div className="flex items-center justify-between">
+              <div className="flex items-center space-x-4">
+                <button
+                  onClick={togglePlay}
+                  className="text-white p-2 rounded-full hover:bg-white/10"
+                >
+                  {isPlaying ? (
+                    <Pause className="w-6 h-6" />
+                  ) : (
+                    <Play className="w-6 h-6" />
+                  )}
+                </button>
+
+                <button
+                  onClick={toggleMute}
+                  className="text-white p-2 rounded-full hover:bg-white/10"
+                >
+                  {isMuted ? (
+                    <VolumeX className="w-5 h-5" />
+                  ) : (
+                    <Volume2 className="w-5 h-5" />
+                  )}
+                </button>
+
+                <span className="text-white text-sm">
+                  {formatTime(currentTime)} / {formatTime(duration)}
+                </span>
+              </div>
+
+              <div className="flex items-center space-x-2">
+                <button
+                  onClick={handleFullscreen}
+                  className="text-white p-2 rounded-full hover:bg-white/10"
+                >
+                  <Maximize className="w-5 h-5" />
+                </button>
+              </div>
+            </div>
+          </div>
+        )}
+
+        {/* 播放/暂停按钮 */}
+        {!showControls && (
+          <button
+            onClick={togglePlay}
+            className="absolute inset-0 flex items-center justify-center"
+          >
+            {!isPlaying && (
+              <div className="bg-black/50 rounded-full p-4">
+                <Play className="w-12 h-12 text-white" fill="white" />
+              </div>
+            )}
+          </button>
+        )}
+      </div>
+
+      {/* 视频信息 */}
+      <div className="bg-background p-4">
+        <h2 className="text-xl font-bold text-foreground mb-2">
+          {video.title}
+        </h2>
+        
+        {video.description && (
+          <p className="text-muted-foreground mb-4">
+            {video.description}
+          </p>
+        )}
+
+        <div className="text-sm text-muted-foreground">
+          <p>上传时间: {new Date(video.createdAt).toLocaleDateString('zh-CN')}</p>
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default VideoPlayerPage;

+ 4 - 6
src/client/mobile/pages/VideoReplayPage.tsx

@@ -1,4 +1,5 @@
 import React, { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router';
 import { Play, Clock, Eye, Calendar, Video } from 'lucide-react';
 import { vodVideoClient } from '@/client/api';
 import type { InferResponseType } from 'hono/client';
@@ -7,6 +8,7 @@ import type { InferResponseType } from 'hono/client';
 type VodVideoResponse = InferResponseType<typeof vodVideoClient.$get, 200>['data'][0];
 
 const VideoReplayPage: React.FC = () => {
+  const navigate = useNavigate();
   const [videos, setVideos] = useState<VodVideoResponse[]>([]);
   const [loading, setLoading] = useState(true);
   const [error, setError] = useState<string>('');
@@ -71,12 +73,8 @@ const VideoReplayPage: React.FC = () => {
   };
 
   const handleVideoClick = (video: VodVideoResponse) => {
-    // 处理视频点击,这里可以跳转到视频播放页面
-    console.log('播放视频:', video.id, video.videoUrl);
-    // 在实际应用中,可以打开视频播放器或跳转到播放页面
-    if (video.videoUrl) {
-      window.open(video.videoUrl, '_blank');
-    }
+    // 跳转到视频播放页面
+    navigate(`/mobile/video/${video.id}`);
   };
 
   if (loading) {

+ 10 - 0
src/client/mobile/routes.tsx

@@ -19,6 +19,7 @@ import ExamAdmin from './components/Exam/ExamAdmin';
 import ExamCard from './components/Exam/ExamCard';
 import MemberPage from './pages/MemberPage';
 import VideoReplayPage from './pages/VideoReplayPage';
+import VideoPlayerPage from './pages/VideoPlayerPage';
 import { MainLayout } from './layouts/MainLayout';
 import { getGlobalConfig } from '../utils/utils';
 import { UserType } from '@/server/modules/users/user.enum';
@@ -117,6 +118,15 @@ export const router = createBrowserRouter([
         ),
         errorElement: <ErrorPage />
       },
+      {
+        path: 'video/:id',
+        element: (
+          <ProtectedRoute>
+            <VideoPlayerPage />
+          </ProtectedRoute>
+        ),
+        errorElement: <ErrorPage />
+      },
       {
         path: '*',
         element: <NotFoundPage />,