Merge branch 'main' of http://113.45.157.195:3003/insiinc/re-mooc
This commit is contained in:
commit
02c53a8180
|
@ -1,84 +1,85 @@
|
||||||
import { PrismaClient, Resource } from '@prisma/client'
|
import { PrismaClient, Resource } from '@prisma/client';
|
||||||
import { ProcessResult, ResourceProcessor } from '../types'
|
import { ProcessResult, ResourceProcessor } from '../types';
|
||||||
import { db, ResourceStatus } from '@nice/common'
|
import { db, ResourceStatus } from '@nice/common';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
|
||||||
// Pipeline 类
|
// Pipeline 类
|
||||||
export class ResourceProcessingPipeline {
|
export class ResourceProcessingPipeline {
|
||||||
private processors: ResourceProcessor[] = []
|
private processors: ResourceProcessor[] = [];
|
||||||
private logger = new Logger(ResourceProcessingPipeline.name);
|
private logger = new Logger(ResourceProcessingPipeline.name);
|
||||||
|
|
||||||
constructor() { }
|
constructor() {}
|
||||||
|
|
||||||
// 添加处理器
|
// 添加处理器
|
||||||
addProcessor(processor: ResourceProcessor): ResourceProcessingPipeline {
|
addProcessor(processor: ResourceProcessor): ResourceProcessingPipeline {
|
||||||
this.processors.push(processor)
|
this.processors.push(processor);
|
||||||
return this
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行处理管道
|
// 执行处理管道
|
||||||
async execute(resource: Resource): Promise<ProcessResult> {
|
async execute(resource: Resource): Promise<ProcessResult> {
|
||||||
let currentResource = resource
|
let currentResource = resource;
|
||||||
try {
|
try {
|
||||||
this.logger.log(`开始处理资源: ${resource.id}`)
|
this.logger.log(`开始处理资源: ${resource.id}`);
|
||||||
|
|
||||||
currentResource = await this.updateProcessStatus(
|
currentResource = await this.updateProcessStatus(
|
||||||
resource.id,
|
resource.id,
|
||||||
ResourceStatus.PROCESSING
|
ResourceStatus.PROCESSING,
|
||||||
)
|
);
|
||||||
this.logger.log(`资源状态已更新为处理中`)
|
this.logger.log(`资源状态已更新为处理中`);
|
||||||
|
|
||||||
for (const processor of this.processors) {
|
for (const processor of this.processors) {
|
||||||
const processorName = processor.constructor.name
|
const processorName = processor.constructor.name;
|
||||||
this.logger.log(`开始执行处理器: ${processorName}`)
|
this.logger.log(`开始执行处理器: ${processorName}`);
|
||||||
|
|
||||||
currentResource = await this.updateProcessStatus(
|
currentResource = await this.updateProcessStatus(
|
||||||
currentResource.id,
|
currentResource.id,
|
||||||
processor.constructor.name as ResourceStatus
|
processor.constructor.name as ResourceStatus,
|
||||||
)
|
);
|
||||||
|
|
||||||
currentResource = await processor.process(currentResource)
|
currentResource = await processor.process(currentResource);
|
||||||
this.logger.log(`处理器 ${processorName} 执行完成`)
|
this.logger.log(`处理器 ${processorName} 执行完成`);
|
||||||
|
|
||||||
currentResource = await db.resource.update({
|
currentResource = await db.resource.update({
|
||||||
where: { id: currentResource.id },
|
where: { id: currentResource.id },
|
||||||
data: currentResource
|
data: currentResource,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
currentResource = await this.updateProcessStatus(
|
currentResource = await this.updateProcessStatus(
|
||||||
currentResource.id,
|
currentResource.id,
|
||||||
ResourceStatus.PROCESSED
|
ResourceStatus.PROCESSED,
|
||||||
)
|
);
|
||||||
this.logger.log(`资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.metadata)}`)
|
this.logger.log(
|
||||||
|
`资源 ${resource.id} 处理成功 ${JSON.stringify(currentResource.metadata)}`,
|
||||||
return {
|
);
|
||||||
success: true,
|
|
||||||
resource: currentResource
|
return {
|
||||||
}
|
success: true,
|
||||||
} catch (error) {
|
resource: currentResource,
|
||||||
this.logger.error(`资源 ${resource.id} 处理失败:`, error)
|
};
|
||||||
|
} catch (error) {
|
||||||
currentResource = await this.updateProcessStatus(
|
this.logger.error(`资源 ${resource.id} 处理失败:`, error);
|
||||||
currentResource.id,
|
|
||||||
ResourceStatus.PROCESS_FAILED
|
currentResource = await this.updateProcessStatus(
|
||||||
)
|
currentResource.id,
|
||||||
|
ResourceStatus.PROCESS_FAILED,
|
||||||
return {
|
);
|
||||||
success: false,
|
|
||||||
resource: currentResource,
|
return {
|
||||||
error: error as Error
|
success: false,
|
||||||
}
|
resource: currentResource,
|
||||||
}
|
error: error as Error,
|
||||||
}
|
};
|
||||||
private async updateProcessStatus(
|
|
||||||
resourceId: string,
|
|
||||||
status: ResourceStatus
|
|
||||||
): Promise<Resource> {
|
|
||||||
return db.resource.update({
|
|
||||||
where: { id: resourceId },
|
|
||||||
data: { status }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
private async updateProcessStatus(
|
||||||
|
resourceId: string,
|
||||||
|
status: ResourceStatus,
|
||||||
|
): Promise<Resource> {
|
||||||
|
return db.resource.update({
|
||||||
|
where: { id: resourceId },
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
import path from "path";
|
import path from 'path';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { FileMetadata, ImageMetadata, ResourceProcessor } from "../types";
|
import { FileMetadata, ImageMetadata, ResourceProcessor } from '../types';
|
||||||
import { Resource, ResourceStatus, db } from "@nice/common";
|
import { Resource, ResourceStatus, db } from '@nice/common';
|
||||||
import { getUploadFilePath } from "@server/utils/file";
|
import { getUploadFilePath } from '@server/utils/file';
|
||||||
import { BaseProcessor } from "./BaseProcessor";
|
import { BaseProcessor } from './BaseProcessor';
|
||||||
|
|
||||||
export class ImageProcessor extends BaseProcessor {
|
export class ImageProcessor extends BaseProcessor {
|
||||||
constructor() { super() }
|
constructor() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
async process(resource: Resource): Promise<Resource> {
|
async process(resource: Resource): Promise<Resource> {
|
||||||
const { url } = resource;
|
const { url } = resource;
|
||||||
|
@ -23,13 +25,16 @@ export class ImageProcessor extends BaseProcessor {
|
||||||
throw new Error(`Failed to get metadata for image: ${url}`);
|
throw new Error(`Failed to get metadata for image: ${url}`);
|
||||||
}
|
}
|
||||||
// Create WebP compressed version
|
// Create WebP compressed version
|
||||||
const compressedDir = this.createOutputDir(filepath, "compressed")
|
const compressedDir = this.createOutputDir(filepath, 'compressed');
|
||||||
const compressedPath = path.join(compressedDir, `${path.basename(filepath, path.extname(filepath))}.webp`);
|
const compressedPath = path.join(
|
||||||
|
compressedDir,
|
||||||
|
`${path.basename(filepath, path.extname(filepath))}.webp`,
|
||||||
|
);
|
||||||
await image
|
await image
|
||||||
.webp({
|
.webp({
|
||||||
quality: 80,
|
quality: 80,
|
||||||
lossless: false,
|
lossless: false,
|
||||||
effort: 5 // Range 0-6, higher means slower but better compression
|
effort: 5, // Range 0-6, higher means slower but better compression
|
||||||
})
|
})
|
||||||
.toFile(compressedPath);
|
.toFile(compressedPath);
|
||||||
const imageMeta: ImageMetadata = {
|
const imageMeta: ImageMetadata = {
|
||||||
|
@ -38,15 +43,15 @@ export class ImageProcessor extends BaseProcessor {
|
||||||
orientation: metadata.orientation,
|
orientation: metadata.orientation,
|
||||||
space: metadata.space,
|
space: metadata.space,
|
||||||
hasAlpha: metadata.hasAlpha,
|
hasAlpha: metadata.hasAlpha,
|
||||||
}
|
};
|
||||||
const updatedResource = await db.resource.update({
|
const updatedResource = await db.resource.update({
|
||||||
where: { id: resource.id },
|
where: { id: resource.id },
|
||||||
data: {
|
data: {
|
||||||
metadata: {
|
metadata: {
|
||||||
...originMeta,
|
...originMeta,
|
||||||
...imageMeta
|
...imageMeta,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return updatedResource;
|
return updatedResource;
|
||||||
|
@ -54,5 +59,4 @@ export class ImageProcessor extends BaseProcessor {
|
||||||
throw new Error(`Failed to process image: ${error.message}`);
|
throw new Error(`Failed to process image: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|
|
@ -1,167 +1,190 @@
|
||||||
import path, { dirname } from "path";
|
import path, { dirname } from 'path';
|
||||||
import ffmpeg from 'fluent-ffmpeg';
|
import ffmpeg from 'fluent-ffmpeg';
|
||||||
import { FileMetadata, VideoMetadata, ResourceProcessor } from "../types";
|
import { FileMetadata, VideoMetadata, ResourceProcessor } from '../types';
|
||||||
import { Resource, ResourceStatus, db } from "@nice/common";
|
import { Resource, ResourceStatus, db } from '@nice/common';
|
||||||
import { getUploadFilePath } from "@server/utils/file";
|
import { getUploadFilePath } from '@server/utils/file';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { BaseProcessor } from "./BaseProcessor";
|
import { BaseProcessor } from './BaseProcessor';
|
||||||
|
|
||||||
export class VideoProcessor extends BaseProcessor {
|
export class VideoProcessor extends BaseProcessor {
|
||||||
constructor() { super() }
|
constructor() {
|
||||||
async process(resource: Resource): Promise<Resource> {
|
super();
|
||||||
const { url} = resource;
|
}
|
||||||
const filepath = getUploadFilePath(url);
|
async process(resource: Resource): Promise<Resource> {
|
||||||
this.logger.log(`Processing video for resource ID: ${resource.id}, File ID: ${url}`);
|
const { url } = resource;
|
||||||
|
const filepath = getUploadFilePath(url);
|
||||||
|
this.logger.log(
|
||||||
|
`Processing video for resource ID: ${resource.id}, File ID: ${url}`,
|
||||||
|
);
|
||||||
|
|
||||||
const originMeta = resource.metadata as unknown as FileMetadata;
|
const originMeta = resource.metadata as unknown as FileMetadata;
|
||||||
if (!originMeta.mimeType?.startsWith('video/')) {
|
if (!originMeta.mimeType?.startsWith('video/')) {
|
||||||
this.logger.log(`Skipping non-video resource: ${resource.id}`);
|
this.logger.log(`Skipping non-video resource: ${resource.id}`);
|
||||||
return resource;
|
return resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const streamDir = this.createOutputDir(filepath, 'stream');
|
||||||
|
|
||||||
|
const [m3u8Path, videoMetadata, coverUrl] = await Promise.all([
|
||||||
|
this.generateM3U8Stream(filepath, streamDir),
|
||||||
|
this.getVideoMetadata(filepath),
|
||||||
|
this.generateVideoCover(filepath, dirname(filepath)),
|
||||||
|
]);
|
||||||
|
const videoMeta: VideoMetadata = {
|
||||||
|
...videoMetadata,
|
||||||
|
coverUrl: coverUrl,
|
||||||
|
};
|
||||||
|
const updatedResource = await db.resource.update({
|
||||||
|
where: { id: resource.id },
|
||||||
|
data: {
|
||||||
|
metadata: {
|
||||||
|
...originMeta,
|
||||||
|
...videoMeta,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Successfully processed video for resource ID: ${resource.id}`,
|
||||||
|
);
|
||||||
|
return updatedResource;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to process video for resource ID: ${resource.id}, Error: ${error.message}`,
|
||||||
|
);
|
||||||
|
throw new Error(`Failed to process video: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private async generateVideoCover(
|
||||||
|
filepath: string,
|
||||||
|
outputDir: string,
|
||||||
|
): Promise<string> {
|
||||||
|
this.logger.log(`Generating video cover for: ${filepath}`);
|
||||||
|
const jpgCoverPath = path.join(outputDir, 'cover.jpg');
|
||||||
|
const webpCoverPath = path.join(outputDir, 'cover.webp');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ffmpeg(filepath)
|
||||||
|
.on('end', async () => {
|
||||||
|
try {
|
||||||
|
// 使用 Sharp 将 JPG 转换为 WebP
|
||||||
|
await sharp(jpgCoverPath)
|
||||||
|
.webp({ quality: 80 }) // 设置 WebP 压缩质量
|
||||||
|
.toFile(webpCoverPath);
|
||||||
|
|
||||||
|
// 删除临时 JPG 文件
|
||||||
|
await fs.unlink(jpgCoverPath);
|
||||||
|
this.logger.log(`Video cover generated at: ${webpCoverPath}`);
|
||||||
|
resolve(path.basename(webpCoverPath));
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(
|
||||||
|
`Error converting cover to WebP: ${error.message}`,
|
||||||
|
);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on('error', (err) => {
|
||||||
|
this.logger.error(`Error generating video cover: ${err.message}`);
|
||||||
|
reject(err);
|
||||||
|
})
|
||||||
|
.screenshots({
|
||||||
|
count: 1,
|
||||||
|
folder: outputDir,
|
||||||
|
filename: 'cover.jpg',
|
||||||
|
size: '640x360',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private async getVideoDuration(filepath: string): Promise<number> {
|
||||||
|
this.logger.log(`Getting video duration for file: ${filepath}`);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ffmpeg.ffprobe(filepath, (err, metadata) => {
|
||||||
|
if (err) {
|
||||||
|
this.logger.error(`Error getting video duration: ${err.message}`);
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const duration = metadata.format.duration || 0;
|
||||||
try {
|
this.logger.log(`Video duration: ${duration} seconds`);
|
||||||
const streamDir = this.createOutputDir(filepath, 'stream');
|
resolve(duration);
|
||||||
const [m3u8Path, videoMetadata, coverUrl] = await Promise.all([
|
});
|
||||||
this.generateM3U8Stream(filepath, streamDir),
|
});
|
||||||
this.getVideoMetadata(filepath),
|
}
|
||||||
this.generateVideoCover(filepath, dirname(filepath))
|
private async generateM3U8Stream(
|
||||||
]);
|
filepath: string,
|
||||||
|
outputDir: string,
|
||||||
const videoMeta: VideoMetadata = {
|
): Promise<string> {
|
||||||
...videoMetadata,
|
const m3u8Path = path.join(outputDir, 'index.m3u8');
|
||||||
coverUrl: coverUrl,
|
this.logger.log(
|
||||||
};
|
`Generating M3U8 stream for video: ${filepath}, Output Dir: ${outputDir}`,
|
||||||
|
);
|
||||||
const updatedResource = await db.resource.update({
|
return new Promise<string>((resolve, reject) => {
|
||||||
where: { id: resource.id },
|
ffmpeg(filepath)
|
||||||
data: {
|
.outputOptions([
|
||||||
metadata: {
|
// Improved video encoding settings
|
||||||
...originMeta,
|
'-c:v libx264',
|
||||||
...videoMeta,
|
'-preset medium', // Balance between encoding speed and compression
|
||||||
},
|
'-crf 23', // Constant Rate Factor for quality
|
||||||
},
|
'-profile:v high', // Higher profile for better compression
|
||||||
});
|
'-level:v 4.1', // Updated level for better compatibility
|
||||||
|
// Parallel processing and performance
|
||||||
this.logger.log(`Successfully processed video for resource ID: ${resource.id}`);
|
'-threads 0', // Auto-detect optimal thread count
|
||||||
return updatedResource;
|
'-x264-params keyint=48:min-keyint=48', // More precise GOP control
|
||||||
} catch (error: any) {
|
// HLS specific optimizations
|
||||||
this.logger.error(`Failed to process video for resource ID: ${resource.id}, Error: ${error.message}`);
|
'-hls_time 4', // Shorter segment duration for better adaptive streaming
|
||||||
throw new Error(`Failed to process video: ${error.message}`);
|
'-hls_list_size 0', // Keep all segments in playlist
|
||||||
|
'-hls_flags independent_segments+delete_segments', // Allow segment cleanup
|
||||||
|
// Additional encoding optimizations
|
||||||
|
'-sc_threshold 0', // Disable scene change detection for more consistent segments
|
||||||
|
'-max_muxing_queue_size 1024', // Increase muxing queue size
|
||||||
|
// Output format
|
||||||
|
'-f hls',
|
||||||
|
])
|
||||||
|
.output(m3u8Path)
|
||||||
|
.on('start', (commandLine) => {
|
||||||
|
this.logger.log(`Starting ffmpeg with command: ${commandLine}`);
|
||||||
|
})
|
||||||
|
.on('end', () => {
|
||||||
|
this.logger.log(`Successfully generated M3U8 stream at: ${m3u8Path}`);
|
||||||
|
resolve(m3u8Path);
|
||||||
|
})
|
||||||
|
.on('error', (err) => {
|
||||||
|
const errorMessage = `Error generating M3U8 stream for ${filepath}: ${err.message}`;
|
||||||
|
this.logger.error(errorMessage);
|
||||||
|
reject(new Error(errorMessage));
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private async getVideoMetadata(
|
||||||
|
filepath: string,
|
||||||
|
): Promise<Partial<VideoMetadata>> {
|
||||||
|
this.logger.log(`Getting video metadata for file: ${filepath}`);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ffmpeg.ffprobe(filepath, (err, metadata) => {
|
||||||
|
if (err) {
|
||||||
|
this.logger.error(`Error getting video metadata: ${err.message}`);
|
||||||
|
reject(err);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
const videoStream = metadata.streams.find(
|
||||||
private async generateVideoCover(filepath: string, outputDir: string): Promise<string> {
|
(stream) => stream.codec_type === 'video',
|
||||||
this.logger.log(`Generating video cover for: ${filepath}`);
|
);
|
||||||
const jpgCoverPath = path.join(outputDir, 'cover.jpg');
|
const audioStream = metadata.streams.find(
|
||||||
const webpCoverPath = path.join(outputDir, 'cover.webp');
|
(stream) => stream.codec_type === 'audio',
|
||||||
return new Promise((resolve, reject) => {
|
);
|
||||||
ffmpeg(filepath)
|
const videoMetadata: Partial<VideoMetadata> = {
|
||||||
.on('end', async () => {
|
width: videoStream?.width || 0,
|
||||||
try {
|
height: videoStream?.height || 0,
|
||||||
// 使用 Sharp 将 JPG 转换为 WebP
|
duration: metadata.format.duration || 0,
|
||||||
await sharp(jpgCoverPath)
|
videoCodec: videoStream?.codec_name || '',
|
||||||
.webp({ quality: 80 }) // 设置 WebP 压缩质量
|
audioCodec: audioStream?.codec_name || '',
|
||||||
.toFile(webpCoverPath);
|
};
|
||||||
|
this.logger.log(
|
||||||
// 删除临时 JPG 文件
|
`Extracted video metadata: ${JSON.stringify(videoMetadata)}`,
|
||||||
await fs.unlink(jpgCoverPath);
|
);
|
||||||
|
resolve(videoMetadata);
|
||||||
this.logger.log(`Video cover generated at: ${webpCoverPath}`);
|
});
|
||||||
resolve(path.basename(webpCoverPath));
|
});
|
||||||
} catch (error: any) {
|
}
|
||||||
this.logger.error(`Error converting cover to WebP: ${error.message}`);
|
}
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on('error', (err) => {
|
|
||||||
this.logger.error(`Error generating video cover: ${err.message}`);
|
|
||||||
reject(err);
|
|
||||||
})
|
|
||||||
.screenshots({
|
|
||||||
count: 1,
|
|
||||||
folder: outputDir,
|
|
||||||
filename: 'cover.jpg',
|
|
||||||
size: '640x360'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
private async getVideoDuration(filepath: string): Promise<number> {
|
|
||||||
this.logger.log(`Getting video duration for file: ${filepath}`);
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
ffmpeg.ffprobe(filepath, (err, metadata) => {
|
|
||||||
if (err) {
|
|
||||||
this.logger.error(`Error getting video duration: ${err.message}`);
|
|
||||||
reject(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const duration = metadata.format.duration || 0;
|
|
||||||
this.logger.log(`Video duration: ${duration} seconds`);
|
|
||||||
resolve(duration);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
private async generateM3U8Stream(filepath: string, outputDir: string): Promise<string> {
|
|
||||||
const m3u8Path = path.join(outputDir, 'index.m3u8');
|
|
||||||
this.logger.log(`Generating M3U8 stream for video: ${filepath}, Output Dir: ${outputDir}`);
|
|
||||||
return new Promise<string>((resolve, reject) => {
|
|
||||||
ffmpeg(filepath)
|
|
||||||
.outputOptions([
|
|
||||||
// Improved video encoding settings
|
|
||||||
'-c:v libx264',
|
|
||||||
'-preset medium', // Balance between encoding speed and compression
|
|
||||||
'-crf 23', // Constant Rate Factor for quality
|
|
||||||
'-profile:v high', // Higher profile for better compression
|
|
||||||
'-level:v 4.1', // Updated level for better compatibility
|
|
||||||
// Parallel processing and performance
|
|
||||||
'-threads 0', // Auto-detect optimal thread count
|
|
||||||
'-x264-params keyint=48:min-keyint=48', // More precise GOP control
|
|
||||||
// HLS specific optimizations
|
|
||||||
'-hls_time 4', // Shorter segment duration for better adaptive streaming
|
|
||||||
'-hls_list_size 0', // Keep all segments in playlist
|
|
||||||
'-hls_flags independent_segments+delete_segments', // Allow segment cleanup
|
|
||||||
// Additional encoding optimizations
|
|
||||||
'-sc_threshold 0', // Disable scene change detection for more consistent segments
|
|
||||||
'-max_muxing_queue_size 1024', // Increase muxing queue size
|
|
||||||
// Output format
|
|
||||||
'-f hls',
|
|
||||||
])
|
|
||||||
.output(m3u8Path)
|
|
||||||
.on('start', (commandLine) => {
|
|
||||||
this.logger.log(`Starting ffmpeg with command: ${commandLine}`);
|
|
||||||
})
|
|
||||||
.on('end', () => {
|
|
||||||
this.logger.log(`Successfully generated M3U8 stream at: ${m3u8Path}`);
|
|
||||||
resolve(m3u8Path);
|
|
||||||
})
|
|
||||||
.on('error', (err) => {
|
|
||||||
const errorMessage = `Error generating M3U8 stream for ${filepath}: ${err.message}`;
|
|
||||||
this.logger.error(errorMessage);
|
|
||||||
reject(new Error(errorMessage));
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
private async getVideoMetadata(filepath: string): Promise<Partial<VideoMetadata>> {
|
|
||||||
this.logger.log(`Getting video metadata for file: ${filepath}`);
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
ffmpeg.ffprobe(filepath, (err, metadata) => {
|
|
||||||
if (err) {
|
|
||||||
this.logger.error(`Error getting video metadata: ${err.message}`);
|
|
||||||
reject(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const videoStream = metadata.streams.find(stream => stream.codec_type === 'video');
|
|
||||||
const audioStream = metadata.streams.find(stream => stream.codec_type === 'audio');
|
|
||||||
const videoMetadata: Partial<VideoMetadata> = {
|
|
||||||
width: videoStream?.width || 0,
|
|
||||||
height: videoStream?.height || 0,
|
|
||||||
duration: metadata.format.duration || 0,
|
|
||||||
videoCodec: videoStream?.codec_name || '',
|
|
||||||
audioCodec: audioStream?.codec_name || ''
|
|
||||||
};
|
|
||||||
this.logger.log(`Extracted video metadata: ${JSON.stringify(videoMetadata)}`);
|
|
||||||
resolve(videoMetadata);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -8,41 +8,44 @@ import { UserProfileService } from '@server/auth/utils';
|
||||||
type Context = Awaited<ReturnType<TrpcService['createExpressContext']>>;
|
type Context = Awaited<ReturnType<TrpcService['createExpressContext']>>;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TrpcService {
|
export class TrpcService {
|
||||||
private readonly logger = new Logger(TrpcService.name);
|
private readonly logger = new Logger(TrpcService.name);
|
||||||
|
|
||||||
async createExpressContext(opts: trpcExpress.CreateExpressContextOptions): Promise<{ staff: UserProfile | undefined }> {
|
async createExpressContext(
|
||||||
const token = opts.req.headers.authorization?.split(' ')[1];
|
opts: trpcExpress.CreateExpressContextOptions,
|
||||||
return await UserProfileService.instance.getUserProfileByToken(token);
|
): Promise<{ staff: UserProfile | undefined }> {
|
||||||
|
const token = opts.req.headers.authorization?.split(' ')[1];
|
||||||
|
return await UserProfileService.instance.getUserProfileByToken(token);
|
||||||
|
}
|
||||||
|
async createWSSContext(
|
||||||
|
opts: CreateWSSContextFnOptions,
|
||||||
|
): Promise<{ staff: UserProfile | undefined }> {
|
||||||
|
const token = opts.info.connectionParams?.token;
|
||||||
|
return await UserProfileService.instance.getUserProfileByToken(token);
|
||||||
|
}
|
||||||
|
trpc = initTRPC.context<Context>().create({
|
||||||
|
transformer: superjson,
|
||||||
|
errorFormatter: ({ error, shape }) => {
|
||||||
|
if (error.code !== 'UNAUTHORIZED') {
|
||||||
|
this.logger.error(error.message, error.stack);
|
||||||
|
}
|
||||||
|
return shape;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
procedure = this.trpc.procedure;
|
||||||
|
router = this.trpc.router;
|
||||||
|
mergeRouters = this.trpc.mergeRouters;
|
||||||
|
|
||||||
|
// Define a protected procedure that ensures the user is authenticated
|
||||||
|
protectProcedure = this.procedure.use(async ({ ctx, next }) => {
|
||||||
|
if (!ctx?.staff) {
|
||||||
|
throw new TRPCError({ code: 'UNAUTHORIZED', message: '未授权请求' });
|
||||||
}
|
}
|
||||||
async createWSSContext(opts: CreateWSSContextFnOptions): Promise<{ staff: UserProfile | undefined }> {
|
return next({
|
||||||
const token = opts.info.connectionParams?.token;
|
ctx: {
|
||||||
return await UserProfileService.instance.getUserProfileByToken(token);
|
// User value is confirmed to be non-null at this point
|
||||||
}
|
staff: ctx.staff,
|
||||||
trpc = initTRPC.context<Context>().create({
|
},
|
||||||
transformer: superjson,
|
|
||||||
errorFormatter: ({ error, shape }) => {
|
|
||||||
if (error.code !== 'UNAUTHORIZED') {
|
|
||||||
this.logger.error(error.message, error.stack);
|
|
||||||
}
|
|
||||||
return shape;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
procedure = this.trpc.procedure;
|
|
||||||
router = this.trpc.router;
|
|
||||||
mergeRouters = this.trpc.mergeRouters;
|
|
||||||
|
|
||||||
// Define a protected procedure that ensures the user is authenticated
|
|
||||||
protectProcedure = this.procedure.use(async ({ ctx, next }) => {
|
|
||||||
if (!ctx?.staff) {
|
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED', message: "未授权请求" });
|
|
||||||
}
|
|
||||||
return next({
|
|
||||||
ctx: {
|
|
||||||
// User value is confirmed to be non-null at this point
|
|
||||||
staff: ctx.staff,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,34 +0,0 @@
|
||||||
import { CourseCard } from "@web/src/components/models/course/card/CourseCard";
|
|
||||||
import { CourseDetail } from "@web/src/components/models/course/detail/CourseDetailContent";
|
|
||||||
import { CourseSyllabus } from "@web/src/components/models/course/detail/CourseSyllabus";
|
|
||||||
|
|
||||||
export const CoursePage = () => {
|
|
||||||
// 假设这些数据从API获取
|
|
||||||
const course: any = {
|
|
||||||
/* course data */
|
|
||||||
};
|
|
||||||
const sections: any = [
|
|
||||||
/* sections data */
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto px-4 py-8">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
{/* 左侧课程详情 */}
|
|
||||||
<div className="lg:col-span-2">
|
|
||||||
<CourseDetail course={course} />
|
|
||||||
</div>
|
|
||||||
{/* 右侧课程大纲 */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<CourseCard course={course} />
|
|
||||||
<CourseSyllabus
|
|
||||||
sections={sections}
|
|
||||||
onLectureClick={(lectureId) => {
|
|
||||||
console.log("Clicked lecture:", lectureId);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -52,7 +52,7 @@ export default function InstructorCoursesPage() {
|
||||||
renderItem={(course) => (
|
renderItem={(course) => (
|
||||||
<CourseCard
|
<CourseCard
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate(`/course/${course.id}/detail`, {
|
navigate(`/course/${course.id}/manage`, {
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -1,106 +1,150 @@
|
||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from "react-hook-form";
|
||||||
import { PlusIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
import { Reorder } from 'framer-motion';
|
import { Reorder } from "framer-motion";
|
||||||
import { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from "react";
|
||||||
import FormError from './FormError';
|
import FormError from "./FormError";
|
||||||
import { UUIDGenerator } from '@nice/common';
|
import { UUIDGenerator } from "@nice/common";
|
||||||
interface ArrayFieldProps {
|
interface ArrayFieldProps {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
addButtonText?: string;
|
addButtonText?: string;
|
||||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
}
|
}
|
||||||
type ItemType = { id: string; value: string };
|
type ItemType = { id: string; value: string };
|
||||||
const inputStyles = "w-full rounded-md border border-gray-300 bg-white p-2 outline-none transition-all duration-300 ease-out focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:ring-opacity-50 placeholder:text-gray-400 shadow-sm";
|
const inputStyles =
|
||||||
const buttonStyles = "rounded-md inline-flex items-center gap-1 px-4 py-2 text-sm font-medium transition-colors ";
|
"w-full rounded-md border border-gray-300 bg-white p-2 outline-none transition-all duration-300 ease-out focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:ring-opacity-50 placeholder:text-gray-400 shadow-sm";
|
||||||
export function FormArrayField({ name,
|
const buttonStyles =
|
||||||
label,
|
"rounded-md inline-flex items-center gap-1 px-4 py-2 text-sm font-medium transition-colors ";
|
||||||
placeholder,
|
export function FormArrayField({
|
||||||
addButtonText = "添加项目",
|
name,
|
||||||
inputProps = {} }: ArrayFieldProps) {
|
label,
|
||||||
const { register, watch, setValue, formState: { errors }, trigger } = useFormContext();
|
placeholder,
|
||||||
|
addButtonText = "添加项目",
|
||||||
|
inputProps = {},
|
||||||
|
}: ArrayFieldProps) {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
trigger,
|
||||||
|
} = useFormContext();
|
||||||
|
|
||||||
// 添加 watch 监听
|
// 添加 watch 监听
|
||||||
const watchedValues = watch(name);
|
const watchedValues = watch(name);
|
||||||
const [items, setItems] = useState<ItemType[]>(() =>
|
const [items, setItems] = useState<ItemType[]>(
|
||||||
(watchedValues as string[])?.map(value => ({ id: UUIDGenerator.generate(), value })) || []
|
() =>
|
||||||
);
|
(watchedValues as string[])?.map((value) => ({
|
||||||
// 使用 useEffect 监听表单值变化
|
id: UUIDGenerator.generate(),
|
||||||
useEffect(() => {
|
value,
|
||||||
if (watchedValues) {
|
})) || []
|
||||||
const newItems = watchedValues.map((value: string, index: number) => {
|
);
|
||||||
// 尽量保持现有的 id
|
// 使用 useEffect 监听表单值变化
|
||||||
return { id: items[index]?.id || UUIDGenerator.generate(), value };
|
useEffect(() => {
|
||||||
});
|
if (watchedValues) {
|
||||||
setItems(newItems);
|
const newItems = watchedValues.map(
|
||||||
}
|
(value: string, index: number) => {
|
||||||
}, [watchedValues]);
|
// 尽量保持现有的 id
|
||||||
const error = errors[name]?.message as string;
|
return {
|
||||||
const updateItems = (newItems: ItemType[]) => {
|
id: items[index]?.id || UUIDGenerator.generate(),
|
||||||
setItems(newItems);
|
value,
|
||||||
setValue(name, newItems.map(item => item.value), { shouldDirty: true });
|
};
|
||||||
};
|
}
|
||||||
return (
|
);
|
||||||
<div className="space-y-2">
|
setItems(newItems);
|
||||||
<label className="block text-sm font-medium text-gray-700">{label}</label>
|
}
|
||||||
<div className="space-y-3">
|
}, [watchedValues]);
|
||||||
<Reorder.Group axis="y" values={items} onReorder={updateItems} className="space-y-3">
|
const error = errors[name]?.message as string;
|
||||||
{items.map((item, index) => (
|
const updateItems = (newItems: ItemType[]) => {
|
||||||
<Reorder.Item
|
setItems(newItems);
|
||||||
key={item.id}
|
setValue(
|
||||||
value={item}
|
name,
|
||||||
initial={{ opacity: 0, y: -20 }}
|
newItems.map((item) => item.value),
|
||||||
animate={{ opacity: 1, y: 0 }}
|
{ shouldDirty: true }
|
||||||
exit={{ opacity: 0, y: -20 }}
|
);
|
||||||
className="group"
|
};
|
||||||
>
|
return (
|
||||||
<div className="relative flex items-center">
|
<div className="space-y-2">
|
||||||
<div className="flex-1 relative">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
<input
|
{label}
|
||||||
{...register(`${name}.${index}`)}
|
</label>
|
||||||
{...inputProps}
|
<div className="space-y-3">
|
||||||
value={item.value}
|
<Reorder.Group
|
||||||
onChange={e => {
|
axis="y"
|
||||||
const newItems = items.map(i =>
|
values={items}
|
||||||
i.id === item.id ? { ...i, value: e.target.value } : i
|
onReorder={updateItems}
|
||||||
);
|
className="space-y-3">
|
||||||
updateItems(newItems);
|
{items.map((item, index) => (
|
||||||
}}
|
<Reorder.Item
|
||||||
onBlur={() => {
|
key={item.id}
|
||||||
trigger(name)
|
value={item}
|
||||||
}}
|
initial={{ opacity: 0, y: -20 }}
|
||||||
placeholder={placeholder}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className={inputStyles}
|
exit={{ opacity: 0, y: -20 }}
|
||||||
/>
|
className="group">
|
||||||
{inputProps.maxLength && (
|
<div className="relative flex items-center">
|
||||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-gray-400">
|
<div className="flex-1 relative">
|
||||||
{inputProps.maxLength - (item.value?.length || 0)}
|
<input
|
||||||
</span>
|
{...register(`${name}.${index}`)}
|
||||||
)}
|
{...inputProps}
|
||||||
</div>
|
value={item.value}
|
||||||
<button
|
onChange={(e) => {
|
||||||
type="button"
|
const newItems = items.map((i) =>
|
||||||
onClick={() => updateItems(items.filter(i => i.id !== item.id))}
|
i.id === item.id
|
||||||
className="absolute -right-10 p-2 text-gray-400 hover:text-red-500 transition-colors rounded-md hover:bg-red-50 focus:ring-red-200 opacity-0 group-hover:opacity-100"
|
? {
|
||||||
>
|
...i,
|
||||||
<XMarkIcon className="w-5 h-5" />
|
value: e.target
|
||||||
</button>
|
.value,
|
||||||
</div>
|
}
|
||||||
</Reorder.Item>
|
: i
|
||||||
))}
|
);
|
||||||
</Reorder.Group>
|
updateItems(newItems);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
trigger(name);
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={inputStyles}
|
||||||
|
/>
|
||||||
|
{inputProps.maxLength && (
|
||||||
|
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-gray-400">
|
||||||
|
{inputProps.maxLength -
|
||||||
|
(item.value?.length || 0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
updateItems(
|
||||||
|
items.filter(
|
||||||
|
(i) => i.id !== item.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="absolute -right-10 p-2 text-gray-400 hover:text-red-500 transition-colors rounded-md hover:bg-red-50 focus:ring-red-200 opacity-0 group-hover:opacity-100">
|
||||||
|
<XMarkIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Reorder.Item>
|
||||||
|
))}
|
||||||
|
</Reorder.Group>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => updateItems([...items, { id: UUIDGenerator.generate(), value: '' }])}
|
onClick={() =>
|
||||||
className={`${buttonStyles} text-blue-600 bg-blue-50 hover:bg-blue-100 focus:ring-blue-500`}
|
updateItems([
|
||||||
>
|
...items,
|
||||||
<PlusIcon className="w-4 h-4" />
|
{ id: UUIDGenerator.generate(), value: "" },
|
||||||
{addButtonText}
|
])
|
||||||
</button>
|
}
|
||||||
</div>
|
className={`${buttonStyles} text-blue-600 bg-blue-50 hover:bg-blue-100 focus:ring-blue-500`}>
|
||||||
<FormError error={error}></FormError>
|
<PlusIcon className="w-4 h-4" />
|
||||||
</div>
|
{addButtonText}
|
||||||
);
|
</button>
|
||||||
}
|
</div>
|
||||||
|
<FormError error={error}></FormError>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,156 @@
|
||||||
|
// components/FormDynamicInputs.tsx
|
||||||
|
import { useFieldArray, useFormContext } from "react-hook-form";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { CheckIcon, XMarkIcon, PlusIcon } from "@heroicons/react/24/outline";
|
||||||
|
import FormError from "./FormError";
|
||||||
|
import { TrashIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
|
export interface DynamicFormInputProps
|
||||||
|
extends Omit<
|
||||||
|
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
|
||||||
|
"type"
|
||||||
|
> {
|
||||||
|
name: string;
|
||||||
|
addTitle?: string;
|
||||||
|
subTitle?: string;
|
||||||
|
label: string;
|
||||||
|
type?:
|
||||||
|
| "text"
|
||||||
|
| "textarea"
|
||||||
|
| "password"
|
||||||
|
| "email"
|
||||||
|
| "number"
|
||||||
|
| "tel"
|
||||||
|
| "url"
|
||||||
|
| "search"
|
||||||
|
| "date"
|
||||||
|
| "time"
|
||||||
|
| "datetime-local";
|
||||||
|
rows?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormDynamicInputs({
|
||||||
|
name,
|
||||||
|
addTitle,
|
||||||
|
label,
|
||||||
|
subTitle,
|
||||||
|
type = "text",
|
||||||
|
rows = 4,
|
||||||
|
className,
|
||||||
|
...restProps
|
||||||
|
}: DynamicFormInputProps) {
|
||||||
|
const [focusedIndexes, setFocusedIndexes] = useState<number[]>([]);
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
trigger,
|
||||||
|
control,
|
||||||
|
} = useFormContext();
|
||||||
|
|
||||||
|
const { fields, append, remove } = useFieldArray({
|
||||||
|
control,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
// 添加 onChange 处理函数
|
||||||
|
const handleInputChange = (index: number, value: string) => {
|
||||||
|
setValue(`${name}.${index}`, value, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldTouch: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleBlur = async (index: number) => {
|
||||||
|
setFocusedIndexes(focusedIndexes.filter((i) => i !== index));
|
||||||
|
await trigger(`${name}.${index}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const values = watch(name) || [];
|
||||||
|
const fieldErrors = errors[name] as any;
|
||||||
|
|
||||||
|
const inputClasses = `
|
||||||
|
w-full rounded-md border bg-white p-2 pr-8 outline-none shadow-sm
|
||||||
|
transition-all duration-300 ease-out placeholder:text-gray-400
|
||||||
|
border-gray-300 focus:border-blue-500 focus:ring-blue-200
|
||||||
|
`;
|
||||||
|
|
||||||
|
const InputElement = type === "textarea" ? "textarea" : "input";
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{subTitle && (
|
||||||
|
<label className="block text-sm font-normal text-gray-500">
|
||||||
|
{subTitle}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={field.id}
|
||||||
|
initial={{ opacity: 0, y: -20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 20 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="group relative">
|
||||||
|
<div className="relative">
|
||||||
|
<InputElement
|
||||||
|
{...register(`${name}.${index}`, {
|
||||||
|
onChange: (e) =>
|
||||||
|
handleInputChange(
|
||||||
|
index,
|
||||||
|
e.target.value
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
type={type !== "textarea" ? type : undefined}
|
||||||
|
rows={type === "textarea" ? rows : undefined}
|
||||||
|
{...restProps}
|
||||||
|
onFocus={() =>
|
||||||
|
setFocusedIndexes([
|
||||||
|
...focusedIndexes,
|
||||||
|
index,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
onBlur={() => handleBlur(index)}
|
||||||
|
className={inputClasses}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 修改这部分,将删除按钮放在 input 内部右侧 */}
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center space-x-2">
|
||||||
|
{values[index] && !fieldErrors?.[index] && (
|
||||||
|
<CheckIcon className="text-green-500 w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{fields.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
className="p-1 text-red-500 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
|
<TrashIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{fieldErrors?.[index]?.message && (
|
||||||
|
<FormError error={fieldErrors[index].message} />
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={() => append("")}
|
||||||
|
className="flex items-center gap-1 text-blue-500 hover:text-blue-600
|
||||||
|
transition-colors px-4 py-2 rounded-lg hover:bg-blue-50">
|
||||||
|
<PlusIcon className="w-5 h-5" />
|
||||||
|
添加新{addTitle || label}
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,84 +0,0 @@
|
||||||
import { CheckCircleIcon } from "@heroicons/react/24/outline";
|
|
||||||
import { Course } from "@nice/common";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import React from "react";
|
|
||||||
import CourseDetailSkeleton from "./CourseDetailSkeleton";
|
|
||||||
interface CourseDetailProps {
|
|
||||||
course: Course;
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CourseDetailContent: React.FC<CourseDetailProps> = ({
|
|
||||||
course,
|
|
||||||
isLoading,
|
|
||||||
}) => {
|
|
||||||
if (isLoading || !course) {
|
|
||||||
return <CourseDetailSkeleton />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* 课程标题区域 */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h1 className="text-3xl font-bold">{course.title}</h1>
|
|
||||||
{course.subTitle && (
|
|
||||||
<p className="text-xl text-gray-600">{course.subTitle}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 课程描述 */}
|
|
||||||
<div className="prose max-w-none">
|
|
||||||
<p>{course.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 学习目标 */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold mb-4">学习目标</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{course.objectives.map((objective, index) => (
|
|
||||||
<div key={index} className="flex items-start gap-2">
|
|
||||||
<CheckCircleIcon className="w-5 h-5 text-green-500 flex-shrink-0 mt-1" />
|
|
||||||
<span>{objective}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 适合人群 */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold mb-4">适合人群</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
{course.audiences.map((audience, index) => (
|
|
||||||
<div key={index} className="flex items-start gap-2">
|
|
||||||
<CheckCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0 mt-1" />
|
|
||||||
<span>{audience}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 课程要求 */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold mb-4">课程要求</h2>
|
|
||||||
<ul className="list-disc list-inside space-y-2 text-gray-700">
|
|
||||||
{course.requirements.map((requirement, index) => (
|
|
||||||
<li key={index}>{requirement}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 可获得技能 */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold mb-4">可获得技能</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{course.skills.map((skill, index) => (
|
|
||||||
<span
|
|
||||||
key={index}
|
|
||||||
className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
|
|
||||||
{skill}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -9,6 +9,8 @@ interface CourseDetailContextType {
|
||||||
selectedLectureId?: string | undefined;
|
selectedLectureId?: string | undefined;
|
||||||
setSelectedLectureId?: React.Dispatch<React.SetStateAction<string>>;
|
setSelectedLectureId?: React.Dispatch<React.SetStateAction<string>>;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
isHeaderVisible: boolean; // 新增
|
||||||
|
setIsHeaderVisible: (visible: boolean) => void; // 新增
|
||||||
}
|
}
|
||||||
interface CourseFormProviderProps {
|
interface CourseFormProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
@ -34,7 +36,7 @@ export function CourseDetailProvider({
|
||||||
const [selectedLectureId, setSelectedLectureId] = useState<
|
const [selectedLectureId, setSelectedLectureId] = useState<
|
||||||
string | undefined
|
string | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
const [isHeaderVisible, setIsHeaderVisible] = useState(true); // 新增
|
||||||
return (
|
return (
|
||||||
<CourseDetailContext.Provider
|
<CourseDetailContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
@ -43,6 +45,8 @@ export function CourseDetailProvider({
|
||||||
selectedLectureId,
|
selectedLectureId,
|
||||||
setSelectedLectureId,
|
setSelectedLectureId,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isHeaderVisible,
|
||||||
|
setIsHeaderVisible,
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</CourseDetailContext.Provider>
|
</CourseDetailContext.Provider>
|
||||||
|
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { CheckCircleIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { Course } from "@nice/common";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import React from "react";
|
||||||
|
import CourseDetailSkeleton from "../CourseDetailSkeleton";
|
||||||
|
import CourseDetailNavBar from "./CourseDetailNavBar";
|
||||||
|
interface CourseDetailProps {
|
||||||
|
course: Course;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CourseDetailDescription: React.FC<CourseDetailProps> = ({
|
||||||
|
course,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CourseDetailNavBar></CourseDetailNavBar>
|
||||||
|
|
||||||
|
<div className="w-[80%] mx-auto px-4 py-8">
|
||||||
|
{isLoading || !course ? (
|
||||||
|
<CourseDetailSkeleton />
|
||||||
|
) : (
|
||||||
|
<CourseDetailSkeleton />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { NavBar } from "@web/src/components/presentation/NavBar";
|
||||||
|
import { HomeIcon, BellIcon } from "@heroicons/react/24/outline";
|
||||||
|
import {
|
||||||
|
DocumentTextIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
|
StarIcon,
|
||||||
|
} from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
|
export default function CourseDetailNavBar() {
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
id: "search",
|
||||||
|
icon: <MagnifyingGlassIcon className="w-5 h-5" />,
|
||||||
|
label: "搜索",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "overview",
|
||||||
|
icon: <HomeIcon className="w-5 h-5" />,
|
||||||
|
label: "概述",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notes",
|
||||||
|
icon: <DocumentTextIcon className="w-5 h-5" />,
|
||||||
|
label: "备注",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "announcements",
|
||||||
|
icon: <BellIcon className="w-5 h-5" />,
|
||||||
|
label: "公告",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reviews",
|
||||||
|
icon: <StarIcon className="w-5 h-5" />,
|
||||||
|
label: "评价",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className=" bg-gray-50">
|
||||||
|
<NavBar
|
||||||
|
items={navItems}
|
||||||
|
defaultSelected="overview"
|
||||||
|
onSelect={(id) => console.log("Selected:", id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { CourseDetailContext } from "../../CourseDetailContext";
|
||||||
|
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
|
export function Overview() {
|
||||||
|
const { course } = useContext(CourseDetailContext);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* 课程描述 */}
|
||||||
|
<div className="prose max-w-none">
|
||||||
|
<p>{course?.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 学习目标 */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">学习目标</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{course?.objectives.map((objective, index) => (
|
||||||
|
<div key={index} className="flex items-start gap-2">
|
||||||
|
<CheckCircleIcon className="w-5 h-5 text-green-500 flex-shrink-0 mt-1" />
|
||||||
|
<span>{objective}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 适合人群 */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">适合人群</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{course?.audiences.map((audience, index) => (
|
||||||
|
<div key={index} className="flex items-start gap-2">
|
||||||
|
<CheckCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0 mt-1" />
|
||||||
|
<span>{audience}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 课程要求 */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">课程要求</h2>
|
||||||
|
<ul className="list-disc list-inside space-y-2 text-gray-700">
|
||||||
|
{course?.requirements.map((requirement, index) => (
|
||||||
|
<li key={index}>{requirement}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 可获得技能 */}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-4">可获得技能</h2>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{course?.skills.map((skill, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
|
||||||
|
{skill}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,23 +1,20 @@
|
||||||
// components/CourseVideoPage.tsx
|
// components/CourseDetailDisplayArea.tsx
|
||||||
import { motion, useScroll, useTransform } from "framer-motion";
|
import { motion, useScroll, useTransform } from "framer-motion";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
import { VideoPlayer } from "@web/src/components/presentation/video-player/VideoPlayer";
|
||||||
import { CourseDetailContent } from "./CourseDetailContent";
|
import { CourseDetailDescription } from "./CourseDetailDescription/CourseDetailDescription";
|
||||||
import { Course } from "@nice/common";
|
import { Course } from "@nice/common";
|
||||||
|
|
||||||
interface CourseVideoPageProps {
|
interface CourseDetailDisplayAreaProps {
|
||||||
course: Course;
|
course: Course;
|
||||||
videoSrc?: string;
|
videoSrc?: string;
|
||||||
videoPoster?: string;
|
videoPoster?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CourseVideoPage: React.FC<CourseVideoPageProps> = ({
|
export const CourseDetailDisplayArea: React.FC<
|
||||||
course,
|
CourseDetailDisplayAreaProps
|
||||||
videoSrc,
|
> = ({ course, videoSrc, videoPoster, isLoading = false }) => {
|
||||||
videoPoster,
|
|
||||||
isLoading = false,
|
|
||||||
}) => {
|
|
||||||
// 创建滚动动画效果
|
// 创建滚动动画效果
|
||||||
const { scrollY } = useScroll();
|
const { scrollY } = useScroll();
|
||||||
const videoScale = useTransform(scrollY, [0, 200], [1, 0.8]);
|
const videoScale = useTransform(scrollY, [0, 200], [1, 0.8]);
|
||||||
|
@ -26,13 +23,16 @@ export const CourseVideoPage: React.FC<CourseVideoPageProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* 固定的视频区域 */}
|
{/* 固定的视频区域 */}
|
||||||
|
{/* 移除 sticky 定位,让视频区域随页面滚动 */}
|
||||||
<motion.div
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
style={{
|
style={{
|
||||||
// scale: videoScale,
|
|
||||||
opacity: videoOpacity,
|
opacity: videoOpacity,
|
||||||
}}
|
}}
|
||||||
className="sticky top-0 w-full bg-black z-10">
|
className="w-full bg-black">
|
||||||
<div className="max-w-6xl mx-auto px-4">
|
<div className=" w-full ">
|
||||||
<VideoPlayer src={videoSrc} poster={videoPoster} />
|
<VideoPlayer src={videoSrc} poster={videoPoster} />
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
@ -42,10 +42,13 @@ export const CourseVideoPage: React.FC<CourseVideoPageProps> = ({
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5, delay: 0.2 }}
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
className="max-w-6xl mx-auto px-4 py-8">
|
className="w-full">
|
||||||
<CourseDetailContent course={course} isLoading={isLoading} />
|
<CourseDetailDescription
|
||||||
|
course={course}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default CourseVideoPage;
|
export default CourseDetailDisplayArea;
|
|
@ -0,0 +1,57 @@
|
||||||
|
// components/Header.tsx
|
||||||
|
import { motion, useScroll, useTransform } from "framer-motion";
|
||||||
|
import { useContext, useEffect, useState } from "react";
|
||||||
|
import { CourseDetailContext } from "../CourseDetailContext";
|
||||||
|
|
||||||
|
export const CourseDetailHeader = () => {
|
||||||
|
const { scrollY } = useScroll();
|
||||||
|
|
||||||
|
const [lastScrollY, setLastScrollY] = useState(0);
|
||||||
|
const { course, isHeaderVisible, setIsHeaderVisible } =
|
||||||
|
useContext(CourseDetailContext);
|
||||||
|
useEffect(() => {
|
||||||
|
const updateHeader = () => {
|
||||||
|
const current = scrollY.get();
|
||||||
|
const direction = current > lastScrollY ? "down" : "up";
|
||||||
|
|
||||||
|
if (direction === "down" && current > 100) {
|
||||||
|
setIsHeaderVisible(false);
|
||||||
|
} else if (direction === "up") {
|
||||||
|
setIsHeaderVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastScrollY(current);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用 requestAnimationFrame 来优化性能
|
||||||
|
const unsubscribe = scrollY.on("change", () => {
|
||||||
|
requestAnimationFrame(updateHeader);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [lastScrollY, scrollY, setIsHeaderVisible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.header
|
||||||
|
initial={{ y: 0 }}
|
||||||
|
animate={{ y: isHeaderVisible ? 0 : -100 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||||
|
className="fixed top-0 left-0 w-full h-16 bg-slate-900 backdrop-blur-sm z-50 shadow-sm">
|
||||||
|
<div className="w-full mx-auto px-4 h-full flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<h1 className="text-white text-xl ">{course?.title}</h1>
|
||||||
|
</div>
|
||||||
|
<nav className="flex items-center space-x-4">
|
||||||
|
{/* 添加你的导航项目 */}
|
||||||
|
<button className="px-4 py-2 rounded-full bg-blue-500 text-white hover:bg-blue-600 transition-colors">
|
||||||
|
开始学习
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</motion.header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CourseDetailHeader;
|
|
@ -1,9 +1,10 @@
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import { CourseDetailContext } from "./CourseDetailContext";
|
import { CourseDetailContext } from "./CourseDetailContext";
|
||||||
import { CourseSyllabus } from "./CourseSyllabus";
|
|
||||||
import { CourseDetailContent } from "./CourseDetailContent";
|
import CourseDetailDisplayArea from "./CourseDetailDisplayArea";
|
||||||
import CourseVideoPage from "./CourseVideoPage";
|
import { CourseSyllabus } from "./CourseSyllabus/CourseSyllabus";
|
||||||
|
import CourseDetailHeader from "./CourseDetailHeader/CourseDetailHeader";
|
||||||
|
|
||||||
export default function CourseDetailLayout() {
|
export default function CourseDetailLayout() {
|
||||||
const { course, selectedLectureId, isLoading, setSelectedLectureId } =
|
const { course, selectedLectureId, isLoading, setSelectedLectureId } =
|
||||||
|
@ -15,28 +16,33 @@ export default function CourseDetailLayout() {
|
||||||
const [isSyllabusOpen, setIsSyllabusOpen] = useState(false);
|
const [isSyllabusOpen, setIsSyllabusOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
<CourseDetailHeader /> {/* 添加 Header 组件 */}
|
||||||
{/* 主内容区域 */}
|
{/* 主内容区域 */}
|
||||||
<motion.div
|
{/* 为了防止 Header 覆盖内容,添加上边距 */}
|
||||||
animate={{
|
<div className="pt-16">
|
||||||
width: isSyllabusOpen ? "66.666667%" : "100%",
|
{" "}
|
||||||
}}
|
{/* 添加这个包装 div */}
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
<motion.div
|
||||||
className="relative">
|
animate={{
|
||||||
<CourseVideoPage
|
width: isSyllabusOpen ? "75%" : "100%",
|
||||||
course={course}
|
}}
|
||||||
isLoading={isLoading}
|
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||||
videoSrc="http://localhost/uploads/2025/01/08/SvWL48Gjg0/stream/index.m3u8"
|
className="relative">
|
||||||
videoPoster="http://localhost/uploads/2025/01/08/SvWL48Gjg0/cover.webp"
|
<CourseDetailDisplayArea
|
||||||
|
course={course}
|
||||||
|
isLoading={isLoading}
|
||||||
|
videoSrc="https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8"
|
||||||
|
videoPoster="https://picsum.photos/800/450"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
{/* 课程大纲侧边栏 */}
|
||||||
|
<CourseSyllabus
|
||||||
|
sections={course?.sections || []}
|
||||||
|
onLectureClick={handleLectureClick}
|
||||||
|
isOpen={isSyllabusOpen}
|
||||||
|
onToggle={() => setIsSyllabusOpen(!isSyllabusOpen)}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
{/* 课程大纲侧边栏 */}
|
|
||||||
<CourseSyllabus
|
|
||||||
sections={course?.sections || []}
|
|
||||||
onLectureClick={handleLectureClick}
|
|
||||||
isOpen={isSyllabusOpen}
|
|
||||||
onToggle={() => setIsSyllabusOpen(!isSyllabusOpen)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,214 +0,0 @@
|
||||||
import { XMarkIcon, BookOpenIcon } from "@heroicons/react/24/outline";
|
|
||||||
import {
|
|
||||||
ChevronDownIcon,
|
|
||||||
ClockIcon,
|
|
||||||
PlayCircleIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
|
||||||
import React, { useState, useRef } from "react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { SectionDto } from "@nice/common";
|
|
||||||
|
|
||||||
interface CourseSyllabusProps {
|
|
||||||
sections: SectionDto[];
|
|
||||||
onLectureClick?: (lectureId: string) => void;
|
|
||||||
isOpen: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
|
||||||
sections,
|
|
||||||
onLectureClick,
|
|
||||||
isOpen,
|
|
||||||
onToggle,
|
|
||||||
}) => {
|
|
||||||
const [expandedSections, setExpandedSections] = useState<string[]>([]);
|
|
||||||
const sectionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
|
||||||
|
|
||||||
const toggleSection = (sectionId: string) => {
|
|
||||||
setExpandedSections((prev) =>
|
|
||||||
prev.includes(sectionId)
|
|
||||||
? prev.filter((id) => id !== sectionId)
|
|
||||||
: [...prev, sectionId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 平滑滚动到选中的章节
|
|
||||||
setTimeout(() => {
|
|
||||||
sectionRefs.current[sectionId]?.scrollIntoView({
|
|
||||||
behavior: "smooth",
|
|
||||||
block: "start",
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={false}
|
|
||||||
animate={{
|
|
||||||
width: isOpen ? "33.333333%" : "48px",
|
|
||||||
right: 0,
|
|
||||||
}}
|
|
||||||
className="fixed top-0 bottom-0 z-20 bg-white shadow-xl">
|
|
||||||
{/* 收起时显示的展开按钮 */}
|
|
||||||
{!isOpen && (
|
|
||||||
<motion.button
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
onClick={onToggle}
|
|
||||||
className="h-full w-12 flex items-center justify-center hover:bg-gray-100">
|
|
||||||
<BookOpenIcon className="w-6 h-6 text-gray-600" />
|
|
||||||
</motion.button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 展开的课程大纲 */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{isOpen && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: 20 }}
|
|
||||||
className="h-full flex flex-col">
|
|
||||||
{/* 标题栏 */}
|
|
||||||
<div className="p-4 border-b flex items-center justify-between">
|
|
||||||
<h2 className="text-xl font-semibold">课程大纲</h2>
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-full">
|
|
||||||
<XMarkIcon className="w-6 h-6 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 课程大纲内容 */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 原有的 sections mapping 内容 */}
|
|
||||||
{sections.map((section) => (
|
|
||||||
<motion.div
|
|
||||||
key={section.id}
|
|
||||||
ref={(el) =>
|
|
||||||
(sectionRefs.current[section.id] =
|
|
||||||
el)
|
|
||||||
}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="border rounded-lg overflow-hidden bg-white shadow-sm hover:shadow-md transition-shadow">
|
|
||||||
<button
|
|
||||||
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
|
||||||
onClick={() =>
|
|
||||||
toggleSection(section.id)
|
|
||||||
}>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-lg font-medium text-gray-700">
|
|
||||||
第
|
|
||||||
{Math.floor(section.order)}
|
|
||||||
章
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-left font-medium text-gray-900">
|
|
||||||
{section.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{section.totalLectures}
|
|
||||||
节课 ·{" "}
|
|
||||||
{Math.floor(
|
|
||||||
section.totalDuration /
|
|
||||||
60
|
|
||||||
)}
|
|
||||||
分钟
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<motion.div
|
|
||||||
animate={{
|
|
||||||
rotate: expandedSections.includes(
|
|
||||||
section.id
|
|
||||||
)
|
|
||||||
? 180
|
|
||||||
: 0,
|
|
||||||
}}
|
|
||||||
transition={{ duration: 0.2 }}>
|
|
||||||
<ChevronDownIcon className="w-5 h-5 text-gray-500" />
|
|
||||||
</motion.div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<AnimatePresence>
|
|
||||||
{expandedSections.includes(
|
|
||||||
section.id
|
|
||||||
) && (
|
|
||||||
<motion.div
|
|
||||||
initial={{
|
|
||||||
height: 0,
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
height: "auto",
|
|
||||||
opacity: 1,
|
|
||||||
}}
|
|
||||||
exit={{
|
|
||||||
height: 0,
|
|
||||||
opacity: 0,
|
|
||||||
}}
|
|
||||||
transition={{
|
|
||||||
duration: 0.3,
|
|
||||||
}}
|
|
||||||
className="border-t">
|
|
||||||
{section.lectures.map(
|
|
||||||
(lecture) => (
|
|
||||||
<motion.button
|
|
||||||
key={lecture.id}
|
|
||||||
initial={{
|
|
||||||
opacity: 0,
|
|
||||||
x: -20,
|
|
||||||
}}
|
|
||||||
animate={{
|
|
||||||
opacity: 1,
|
|
||||||
x: 0,
|
|
||||||
}}
|
|
||||||
className="w-full flex items-center gap-4 p-4 hover:bg-gray-50 text-left transition-colors"
|
|
||||||
onClick={() =>
|
|
||||||
onLectureClick?.(
|
|
||||||
lecture.id
|
|
||||||
)
|
|
||||||
}>
|
|
||||||
<PlayCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0" />
|
|
||||||
<div className="flex-grow">
|
|
||||||
<h4 className="font-medium text-gray-800">
|
|
||||||
{
|
|
||||||
lecture.title
|
|
||||||
}
|
|
||||||
</h4>
|
|
||||||
{lecture.description && (
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
{
|
|
||||||
lecture.description
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 text-sm text-gray-500">
|
|
||||||
<ClockIcon className="w-4 h-4" />
|
|
||||||
<span>
|
|
||||||
{
|
|
||||||
lecture.duration
|
|
||||||
}
|
|
||||||
分钟
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</motion.button>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { BookOpenIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import React from "react";
|
||||||
|
interface CollapsedButtonProps {
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CollapsedButton: React.FC<CollapsedButtonProps> = ({
|
||||||
|
onToggle,
|
||||||
|
}) => (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onToggle}
|
||||||
|
className="p-2 bg-white rounded-l-lg shadow-lg hover:bg-gray-100">
|
||||||
|
<BookOpenIcon className="w-6 h-6 text-gray-600" />
|
||||||
|
</motion.button>
|
||||||
|
);
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { XMarkIcon, BookOpenIcon } from "@heroicons/react/24/outline";
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ClockIcon,
|
||||||
|
PlayCircleIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||||
|
import React, { useState, useRef, useContext } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { SectionDto } from "@nice/common";
|
||||||
|
import { SyllabusHeader } from "./SyllabusHeader";
|
||||||
|
import { SectionItem } from "./SectionItem";
|
||||||
|
import { CollapsedButton } from "./CollapsedButton";
|
||||||
|
import { CourseDetailContext } from "../CourseDetailContext";
|
||||||
|
|
||||||
|
interface CourseSyllabusProps {
|
||||||
|
sections: SectionDto[];
|
||||||
|
onLectureClick?: (lectureId: string) => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CourseSyllabus: React.FC<CourseSyllabusProps> = ({
|
||||||
|
sections,
|
||||||
|
onLectureClick,
|
||||||
|
isOpen,
|
||||||
|
onToggle,
|
||||||
|
}) => {
|
||||||
|
const { isHeaderVisible } = useContext(CourseDetailContext);
|
||||||
|
const [expandedSections, setExpandedSections] = useState<string[]>([]);
|
||||||
|
const sectionRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||||
|
|
||||||
|
const toggleSection = (sectionId: string) => {
|
||||||
|
setExpandedSections((prev) =>
|
||||||
|
prev.includes(sectionId)
|
||||||
|
? prev.filter((id) => id !== sectionId)
|
||||||
|
: [...prev, sectionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
sectionRefs.current[sectionId]?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatePresence>
|
||||||
|
{/* 收起时的悬浮按钮 */}
|
||||||
|
{!isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed top-1/3 right-0 -translate-y-1/2 z-20">
|
||||||
|
<CollapsedButton onToggle={onToggle} />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={false}
|
||||||
|
animate={{
|
||||||
|
width: isOpen ? "25%" : "0",
|
||||||
|
right: 0,
|
||||||
|
top: isHeaderVisible ? "64px" : "0",
|
||||||
|
}}
|
||||||
|
className="fixed top-0 bottom-0 z-20 bg-white shadow-xl">
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 20 }}
|
||||||
|
className="h-full flex flex-col">
|
||||||
|
<SyllabusHeader onToggle={onToggle} />
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{sections.map((section) => (
|
||||||
|
<SectionItem
|
||||||
|
key={section.id}
|
||||||
|
ref={(el) =>
|
||||||
|
(sectionRefs.current[
|
||||||
|
section.id
|
||||||
|
] = el)
|
||||||
|
}
|
||||||
|
section={section}
|
||||||
|
isExpanded={expandedSections.includes(
|
||||||
|
section.id
|
||||||
|
)}
|
||||||
|
onToggle={toggleSection}
|
||||||
|
onLectureClick={onLectureClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,36 @@
|
||||||
|
// components/CourseSyllabus/LectureItem.tsx
|
||||||
|
|
||||||
|
import { Lecture } from "@nice/common";
|
||||||
|
import React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ClockIcon, PlayCircleIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
interface LectureItemProps {
|
||||||
|
lecture: Lecture;
|
||||||
|
onClick: (lectureId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LectureItem: React.FC<LectureItemProps> = ({
|
||||||
|
lecture,
|
||||||
|
onClick,
|
||||||
|
}) => (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, x: -20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
className="w-full flex items-center gap-4 p-4 hover:bg-gray-50 text-left transition-colors"
|
||||||
|
onClick={() => onClick(lecture.id)}>
|
||||||
|
<PlayCircleIcon className="w-5 h-5 text-blue-500 flex-shrink-0" />
|
||||||
|
<div className="flex-grow">
|
||||||
|
<h4 className="font-medium text-gray-800">{lecture.title}</h4>
|
||||||
|
{lecture.description && (
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{lecture.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||||
|
<ClockIcon className="w-4 h-4" />
|
||||||
|
<span>{lecture.duration}分钟</span>
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
);
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { SectionDto } from "@nice/common";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import React from "react";
|
||||||
|
import { LectureItem } from "./LectureItem";
|
||||||
|
|
||||||
|
// components/CourseSyllabus/SectionItem.tsx
|
||||||
|
interface SectionItemProps {
|
||||||
|
section: SectionDto;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: (sectionId: string) => void;
|
||||||
|
onLectureClick: (lectureId: string) => void;
|
||||||
|
ref: React.RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SectionItem = React.forwardRef<HTMLDivElement, SectionItemProps>(
|
||||||
|
({ section, isExpanded, onToggle, onLectureClick }, ref) => (
|
||||||
|
<motion.div
|
||||||
|
ref={ref}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="border rounded-lg overflow-hidden bg-white shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
||||||
|
onClick={() => onToggle(section.id)}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-lg font-medium text-gray-700">
|
||||||
|
第{Math.floor(section.order)}章
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-left font-medium text-gray-900">
|
||||||
|
{section.title}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{section.totalLectures}节课 ·{" "}
|
||||||
|
{Math.floor(section.totalDuration / 60)}分钟
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||||
|
transition={{ duration: 0.2 }}>
|
||||||
|
<ChevronDownIcon className="w-5 h-5 text-gray-500" />
|
||||||
|
</motion.div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="border-t">
|
||||||
|
{section.lectures.map((lecture) => (
|
||||||
|
<LectureItem
|
||||||
|
key={lecture.id}
|
||||||
|
lecture={lecture}
|
||||||
|
onClick={onLectureClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
);
|
|
@ -0,0 +1,18 @@
|
||||||
|
// components/CourseSyllabus/SyllabusHeader.tsx
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
|
interface SyllabusHeaderProps {
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SyllabusHeader: React.FC<SyllabusHeaderProps> = ({ onToggle }) => (
|
||||||
|
<div className="p-4 border-b flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-semibold">课程大纲</h2>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-full">
|
||||||
|
<XMarkIcon className="w-6 h-6 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./CourseSyllabus";
|
|
@ -1,11 +1,11 @@
|
||||||
import { createContext, useContext, ReactNode, useEffect } from 'react';
|
import { createContext, useContext, ReactNode, useEffect } from "react";
|
||||||
import { useForm, FormProvider, SubmitHandler } from 'react-hook-form';
|
import { useForm, FormProvider, SubmitHandler } from "react-hook-form";
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
import { CourseDto, CourseLevel, CourseStatus } from '@nice/common';
|
import { CourseDto, CourseLevel, CourseStatus } from "@nice/common";
|
||||||
import { api, useCourse } from '@nice/client';
|
import { api, useCourse } from "@nice/client";
|
||||||
import toast from 'react-hot-toast';
|
import toast from "react-hot-toast";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from "react-router-dom";
|
||||||
// 定义课程表单验证 Schema
|
// 定义课程表单验证 Schema
|
||||||
const courseSchema = z.object({
|
const courseSchema = z.object({
|
||||||
title: z.string().min(1, '课程标题不能为空'),
|
title: z.string().min(1, '课程标题不能为空'),
|
||||||
|
@ -18,13 +18,15 @@ const courseSchema = z.object({
|
||||||
});
|
});
|
||||||
export type CourseFormData = z.infer<typeof courseSchema>;
|
export type CourseFormData = z.infer<typeof courseSchema>;
|
||||||
interface CourseEditorContextType {
|
interface CourseEditorContextType {
|
||||||
onSubmit: SubmitHandler<CourseFormData>;
|
onSubmit: SubmitHandler<CourseFormData>;
|
||||||
editId?: string; // 添加 editId
|
editId?: string; // 添加 editId
|
||||||
course?: CourseDto
|
part?: string;
|
||||||
|
course?: CourseDto;
|
||||||
}
|
}
|
||||||
interface CourseFormProviderProps {
|
interface CourseFormProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
editId?: string; // 添加 editId 参数
|
editId?: string; // 添加 editId 参数
|
||||||
|
part?: string;
|
||||||
}
|
}
|
||||||
const CourseEditorContext = createContext<CourseEditorContextType | null>(null);
|
const CourseEditorContext = createContext<CourseEditorContextType | null>(null);
|
||||||
export function CourseFormProvider({ children, editId }: CourseFormProviderProps) {
|
export function CourseFormProvider({ children, editId }: CourseFormProviderProps) {
|
||||||
|
@ -93,9 +95,11 @@ export function CourseFormProvider({ children, editId }: CourseFormProviderProps
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useCourseEditor = () => {
|
export const useCourseEditor = () => {
|
||||||
const context = useContext(CourseEditorContext);
|
const context = useContext(CourseEditorContext);
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useCourseEditor must be used within CourseFormProvider');
|
throw new Error(
|
||||||
}
|
"useCourseEditor must be used within CourseFormProvider"
|
||||||
return context;
|
);
|
||||||
};
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
|
@ -13,6 +13,7 @@ const courseStatusVariant: Record<CourseStatus, string> = {
|
||||||
};
|
};
|
||||||
export default function CourseEditorHeader() {
|
export default function CourseEditorHeader() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { handleSubmit, formState: { isValid, isDirty, errors } } = useFormContext<CourseFormData>()
|
const { handleSubmit, formState: { isValid, isDirty, errors } } = useFormContext<CourseFormData>()
|
||||||
|
|
||||||
const { onSubmit, course } = useCourseEditor()
|
const { onSubmit, course } = useCourseEditor()
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { SubmitHandler, useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
|
import { CourseFormData } from "../../context/CourseEditorContext";
|
||||||
|
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
||||||
|
import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||||
|
import { FormSelect } from "@web/src/components/common/form/FormSelect";
|
||||||
|
import { convertToOptions } from "@nice/client";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function CourseBasicForm() {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
watch,
|
||||||
|
handleSubmit,
|
||||||
|
} = useFormContext<CourseFormData>();
|
||||||
|
// useEffect(() => {
|
||||||
|
// console.log(watch("audiences"));
|
||||||
|
// }, [watch("audiences")]);
|
||||||
|
return (
|
||||||
|
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||||
|
<FormInput
|
||||||
|
maxLength={20}
|
||||||
|
name="title"
|
||||||
|
label="课程标题"
|
||||||
|
placeholder="请输入课程标题"
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
maxLength={10}
|
||||||
|
name="subTitle"
|
||||||
|
label="课程副标题"
|
||||||
|
placeholder="请输入课程副标题"
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="description"
|
||||||
|
label="课程描述"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="请输入课程描述"
|
||||||
|
/>
|
||||||
|
<FormSelect
|
||||||
|
name="level"
|
||||||
|
label="难度等级"
|
||||||
|
options={convertToOptions(CourseLevelLabel)}></FormSelect>
|
||||||
|
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { SubmitHandler, useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
|
import { CourseFormData } from "../../context/CourseEditorContext";
|
||||||
|
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
||||||
|
import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||||
|
import { FormSelect } from "@web/src/components/common/form/FormSelect";
|
||||||
|
import { convertToOptions } from "@nice/client";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function CourseContentForm() {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
watch,
|
||||||
|
handleSubmit,
|
||||||
|
} = useFormContext<CourseFormData>();
|
||||||
|
// useEffect(() => {
|
||||||
|
// console.log(watch("audiences"));
|
||||||
|
// }, [watch("audiences")]);
|
||||||
|
return (
|
||||||
|
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||||
|
<FormInput
|
||||||
|
maxLength={20}
|
||||||
|
name="title"
|
||||||
|
label="课程标题"
|
||||||
|
placeholder="请输入课程标题"
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
maxLength={10}
|
||||||
|
name="subTitle"
|
||||||
|
label="课程副标题"
|
||||||
|
placeholder="请输入课程副标题"
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="description"
|
||||||
|
label="课程描述"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="请输入课程描述"
|
||||||
|
/>
|
||||||
|
<FormSelect
|
||||||
|
name="level"
|
||||||
|
label="难度等级"
|
||||||
|
options={convertToOptions(CourseLevelLabel)}></FormSelect>
|
||||||
|
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { useCourseEditor } from "../../context/CourseEditorContext";
|
||||||
|
import { CoursePart } from "../enum";
|
||||||
|
import { CourseBasicForm } from "./CourseBasicForm";
|
||||||
|
import { CourseTargetForm } from "./CourseTargetForm";
|
||||||
|
import { CourseContentForm } from "./CourseContentForm";
|
||||||
|
|
||||||
|
export default function CourseForm() {
|
||||||
|
const { part } = useCourseEditor();
|
||||||
|
if (part === CoursePart.OVERVIEW) {
|
||||||
|
return <CourseBasicForm></CourseBasicForm>;
|
||||||
|
}
|
||||||
|
if (part === CoursePart.TARGET) {
|
||||||
|
return <CourseTargetForm></CourseTargetForm>;
|
||||||
|
}
|
||||||
|
if (part === CoursePart.CONTENT) {
|
||||||
|
return <CourseContentForm></CourseContentForm>;
|
||||||
|
}
|
||||||
|
if (part === CoursePart.SETTING) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
return <CourseBasicForm></CourseBasicForm>;
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { SubmitHandler, useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
|
import { CourseFormData } from "../../context/CourseEditorContext";
|
||||||
|
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
||||||
|
import { FormInput } from "@web/src/components/common/form/FormInput";
|
||||||
|
import { FormSelect } from "@web/src/components/common/form/FormSelect";
|
||||||
|
import { convertToOptions } from "@nice/client";
|
||||||
|
|
||||||
|
export function CourseContentForm() {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
watch,
|
||||||
|
handleSubmit,
|
||||||
|
} = useFormContext<CourseFormData>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||||
|
<FormInput
|
||||||
|
maxLength={20}
|
||||||
|
name="title"
|
||||||
|
label="课程标题"
|
||||||
|
placeholder="请输入课程标题"
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
maxLength={10}
|
||||||
|
name="subTitle"
|
||||||
|
label="课程副标题"
|
||||||
|
placeholder="请输入课程副标题"
|
||||||
|
/>
|
||||||
|
<FormInput
|
||||||
|
name="description"
|
||||||
|
label="课程描述"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="请输入课程描述"
|
||||||
|
/>
|
||||||
|
<FormSelect
|
||||||
|
name="level"
|
||||||
|
label="难度等级"
|
||||||
|
options={convertToOptions(CourseLevelLabel)}></FormSelect>
|
||||||
|
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { SubmitHandler, useFormContext } from "react-hook-form";
|
||||||
|
|
||||||
|
import { CourseFormData } from "../../context/CourseEditorContext";
|
||||||
|
import { CourseLevel, CourseLevelLabel } from "@nice/common";
|
||||||
|
import { convertToOptions } from "@nice/client";
|
||||||
|
import { FormDynamicInputs } from "@web/src/components/common/form/FormDynamicInputs";
|
||||||
|
|
||||||
|
export function CourseTargetForm() {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
formState: { errors },
|
||||||
|
watch,
|
||||||
|
handleSubmit,
|
||||||
|
} = useFormContext<CourseFormData>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="max-w-2xl mx-auto space-y-6 p-6">
|
||||||
|
<FormDynamicInputs
|
||||||
|
name="objectives"
|
||||||
|
label="本课的具体学习目标是什么?"
|
||||||
|
// subTitle="学员在完成您的课程后期望掌握的技能"
|
||||||
|
addTitle="目标"></FormDynamicInputs>
|
||||||
|
<FormDynamicInputs
|
||||||
|
name="skills"
|
||||||
|
label="学生将从您的课程中学到什么技能?"
|
||||||
|
subTitle="学员在完成您的课程后期望掌握的技能"
|
||||||
|
addTitle="技能"></FormDynamicInputs>
|
||||||
|
<FormDynamicInputs
|
||||||
|
name="requirements"
|
||||||
|
label="参加课程的要求或基本要求是什么?"
|
||||||
|
subTitle="列出学员在参加课程之前应具备的所需技能、经验、工具或设备。
|
||||||
|
如果没有要求,则可利用此空间作为降低初学者门槛的机会。"
|
||||||
|
addTitle="要求"></FormDynamicInputs>
|
||||||
|
<FormDynamicInputs
|
||||||
|
name="audiences"
|
||||||
|
subTitle="撰写您的课程目标学员的清晰描述,让学员了解您的课程内容很有价值。这将帮助您吸引合适的学员加入您的课程。"
|
||||||
|
addTitle="目标受众"
|
||||||
|
label="此课程的受众是谁?"></FormDynamicInputs>
|
||||||
|
|
||||||
|
{/* <FormArrayField inputProps={{ maxLength: 10 }} name='requirements' label='课程要求'></FormArrayField> */}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
export enum CoursePart {
|
||||||
|
OVERVIEW = "overview",
|
||||||
|
TARGET = "target",
|
||||||
|
CONTENT = "content",
|
||||||
|
SETTING = "settings",
|
||||||
|
}
|
|
@ -3,60 +3,59 @@ import { motion } from "framer-motion";
|
||||||
import { Course, CourseDto } from "@nice/common";
|
import { Course, CourseDto } from "@nice/common";
|
||||||
import { EmptyState } from "@web/src/components/common/space/Empty";
|
import { EmptyState } from "@web/src/components/common/space/Empty";
|
||||||
import { Pagination } from "@web/src/components/common/element/Pagination";
|
import { Pagination } from "@web/src/components/common/element/Pagination";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
interface CourseListProps {
|
interface CourseListProps {
|
||||||
courses?: CourseDto[];
|
courses?: CourseDto[];
|
||||||
renderItem: (course: CourseDto) => React.ReactNode;
|
renderItem: (course: CourseDto) => React.ReactNode;
|
||||||
emptyComponent?: React.ReactNode;
|
emptyComponent?: React.ReactNode;
|
||||||
// 新增分页相关属性
|
// 新增分页相关属性
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
onPageChange: (page: number) => void;
|
onPageChange: (page: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = {
|
const container = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
show: {
|
show: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: {
|
||||||
staggerChildren: 0.05,
|
staggerChildren: 0.05,
|
||||||
duration: 0.3
|
duration: 0.3,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
export const CourseList = ({
|
export const CourseList = ({
|
||||||
courses,
|
courses,
|
||||||
renderItem,
|
renderItem,
|
||||||
emptyComponent: EmptyComponent,
|
emptyComponent: EmptyComponent,
|
||||||
currentPage,
|
currentPage,
|
||||||
totalPages,
|
totalPages,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
}: CourseListProps) => {
|
}: CourseListProps) => {
|
||||||
if (!courses || courses.length === 0) {
|
if (!courses || courses.length === 0) {
|
||||||
return EmptyComponent || <EmptyState />;
|
return EmptyComponent || <EmptyState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<motion.div
|
||||||
|
variants={container}
|
||||||
|
initial="hidden"
|
||||||
|
animate="show"
|
||||||
|
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{courses.map((course) => (
|
||||||
|
<motion.div key={course.id}>
|
||||||
|
{renderItem(course)}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
return (
|
<Pagination
|
||||||
<div>
|
currentPage={currentPage}
|
||||||
<motion.div
|
totalPages={totalPages}
|
||||||
variants={container}
|
onPageChange={onPageChange}
|
||||||
initial="hidden"
|
/>
|
||||||
animate="show"
|
</div>
|
||||||
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
);
|
||||||
>
|
};
|
||||||
{courses.map((course) => (
|
|
||||||
<motion.div key={course.id}>
|
|
||||||
{renderItem(course)}
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<Pagination
|
|
||||||
currentPage={currentPage}
|
|
||||||
totalPages={totalPages}
|
|
||||||
onPageChange={onPageChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
@ -0,0 +1,58 @@
|
||||||
|
// components/NavBar.tsx
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
id: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavBarProps {
|
||||||
|
items: NavItem[];
|
||||||
|
defaultSelected?: string;
|
||||||
|
onSelect?: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NavBar = ({ items, defaultSelected, onSelect }: NavBarProps) => {
|
||||||
|
const [selected, setSelected] = useState(defaultSelected || items[0]?.id);
|
||||||
|
|
||||||
|
const handleSelect = (id: string) => {
|
||||||
|
setSelected(id);
|
||||||
|
onSelect?.(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="bg-white px-4 py-2 shadow-sm">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<ul className="flex items-center space-x-8">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelect(item.id)}
|
||||||
|
className={`flex items-center space-x-2 px-2 py-4 text-sm font-medium transition-colors
|
||||||
|
${selected === item.id ? "text-black" : "text-gray-500 hover:text-gray-800"}`}>
|
||||||
|
{item.icon && (
|
||||||
|
<span className="w-4 h-4">{item.icon}</span>
|
||||||
|
)}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
{selected === item.id && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute bottom-0 left-0 right-0 h-0.5 bg-black"
|
||||||
|
layoutId="underline"
|
||||||
|
initial={false}
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 500,
|
||||||
|
damping: 30,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { SunIcon } from "@heroicons/react/24/solid";
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { VideoPlayerContext } from "../VideoPlayer";
|
||||||
|
|
||||||
|
export default function Brightness() {
|
||||||
|
const { brightness, setBrightness } = useContext(VideoPlayerContext);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 亮度控制 */}
|
||||||
|
<div className="relative group flex items-center">
|
||||||
|
<button className="text-white hover:text-primaryHover">
|
||||||
|
<SunIcon className="w-10 h-10" />
|
||||||
|
</button>
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
|
<div className="bg-black/80 rounded-lg p-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0.1"
|
||||||
|
max="2"
|
||||||
|
step="0.1"
|
||||||
|
value={brightness}
|
||||||
|
onChange={(e) =>
|
||||||
|
setBrightness(parseFloat(e.target.value))
|
||||||
|
}
|
||||||
|
className="h-24 w-2 accent-primary-500 [-webkit-appearance:slider-vertical]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { VideoPlayerContext } from "../VideoPlayer";
|
||||||
|
import {
|
||||||
|
ArrowsPointingInIcon,
|
||||||
|
ArrowsPointingOutIcon,
|
||||||
|
} from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
|
export default function FullScreen() {
|
||||||
|
const { videoRef } = useContext(VideoPlayerContext);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
videoRef.current?.parentElement?.requestFullscreen();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-white hover:text-primary-400">
|
||||||
|
{document.fullscreenElement ? (
|
||||||
|
<ArrowsPointingInIcon className="w-10 h-10" />
|
||||||
|
) : (
|
||||||
|
<ArrowsPointingOutIcon className="w-10 h-10" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { VideoPlayerContext } from "../VideoPlayer";
|
||||||
|
import { PauseIcon, PlayIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
|
export default function Play() {
|
||||||
|
const { isPlaying, videoRef } = useContext(VideoPlayerContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
videoRef.current?.paused
|
||||||
|
? videoRef.current.play()
|
||||||
|
: videoRef.current?.pause()
|
||||||
|
}
|
||||||
|
className="text-white hover:text-primaryHover">
|
||||||
|
{isPlaying ? (
|
||||||
|
<PauseIcon className="w-10 h-10" />
|
||||||
|
) : (
|
||||||
|
<PlayIcon className="w-10 h-10" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { VideoPlayerContext } from "../VideoPlayer";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { Cog6ToothIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
|
export default function Setting() {
|
||||||
|
const {
|
||||||
|
isSettingsOpen,
|
||||||
|
setIsSettingsOpen,
|
||||||
|
resolution,
|
||||||
|
setResolution,
|
||||||
|
resolutions,
|
||||||
|
} = useContext(VideoPlayerContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
|
||||||
|
className="text-white hover:text-primaryHover">
|
||||||
|
<Cog6ToothIcon className="w-10 h-10" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isSettingsOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 10 }}
|
||||||
|
className="absolute right-0 bottom-full mb-2 bg-black/90 rounded-lg p-4 min-w-[200px]">
|
||||||
|
{/* 清晰度选择器 */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="text-white text-sm mb-2">
|
||||||
|
清晰度
|
||||||
|
</div>
|
||||||
|
{resolutions.map((res) => (
|
||||||
|
<button
|
||||||
|
key={res.id}
|
||||||
|
onClick={() => {
|
||||||
|
setResolution(res.id);
|
||||||
|
setIsSettingsOpen(false); // 选择后关闭菜单
|
||||||
|
}}
|
||||||
|
className={`
|
||||||
|
w-full text-left px-3 py-2 rounded
|
||||||
|
${
|
||||||
|
resolution === res.id
|
||||||
|
? "bg-primary text-white"
|
||||||
|
: "text-white/90 hover:bg-white/20"
|
||||||
|
}
|
||||||
|
transition-colors duration-200
|
||||||
|
`}>
|
||||||
|
{res.label || `${res.height}p`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { VideoPlayerContext } from "../VideoPlayer";
|
||||||
|
import { ChevronUpDownIcon } from "@heroicons/react/24/solid";
|
||||||
|
import { PlaybackSpeed } from "../type";
|
||||||
|
|
||||||
|
export default function Speed() {
|
||||||
|
const {
|
||||||
|
setIsSpeedOpen,
|
||||||
|
isSpeedOpen,
|
||||||
|
playbackSpeed,
|
||||||
|
setPlaybackSpeed,
|
||||||
|
videoRef,
|
||||||
|
} = useContext(VideoPlayerContext);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="relative flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSpeedOpen(!isSpeedOpen)}
|
||||||
|
className="text-white hover:text-primaryHover flex items-center">
|
||||||
|
<span className="text-xl font-bold mr-1">
|
||||||
|
{playbackSpeed === 1.0 ? "倍速" : `${playbackSpeed}x`}
|
||||||
|
</span>
|
||||||
|
<ChevronUpDownIcon className="w-10 h-10" />
|
||||||
|
</button>
|
||||||
|
{isSpeedOpen && (
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2">
|
||||||
|
<div className="bg-black/80 rounded-lg p-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{[0.5, 0.75, 1.0, 1.25, 1.5, 2.0].map(
|
||||||
|
(speed) => (
|
||||||
|
<button
|
||||||
|
key={speed}
|
||||||
|
onClick={() => {
|
||||||
|
setPlaybackSpeed(
|
||||||
|
speed as PlaybackSpeed
|
||||||
|
);
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.playbackRate =
|
||||||
|
speed;
|
||||||
|
}
|
||||||
|
setIsSpeedOpen(false);
|
||||||
|
}}
|
||||||
|
className={`px-2 py-1 text-lg whitespace-nowrap ${
|
||||||
|
playbackSpeed === speed
|
||||||
|
? "text-primaryHover font-bold"
|
||||||
|
: "text-white hover:text-primaryHover"
|
||||||
|
}`}>
|
||||||
|
{speed}x
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,64 @@
|
||||||
|
import React, { useContext } from "react";
|
||||||
|
import { VideoPlayerContext } from "../VideoPlayer";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export default function TimeLine() {
|
||||||
|
const {
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
progressRef,
|
||||||
|
setIsDragging,
|
||||||
|
videoRef,
|
||||||
|
isDragging,
|
||||||
|
isHovering,
|
||||||
|
} = useContext(VideoPlayerContext);
|
||||||
|
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (!videoRef.current || !progressRef.current) return;
|
||||||
|
|
||||||
|
const rect = progressRef.current.getBoundingClientRect();
|
||||||
|
const percent = (e.clientX - rect.left) / rect.width;
|
||||||
|
videoRef.current.currentTime = percent * videoRef.current.duration;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={progressRef}
|
||||||
|
className="relative h-1 mb-4 cursor-pointer group"
|
||||||
|
onClick={handleProgressClick}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
setIsDragging(true);
|
||||||
|
handleProgressClick(e);
|
||||||
|
}}>
|
||||||
|
{/* 背景条 */}
|
||||||
|
<div className="absolute w-full h-full bg-gray-500/70 rounded-full" />
|
||||||
|
{/* 播放进度 */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute h-full bg-primary rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${(currentTime / duration) * 100}%`,
|
||||||
|
}}
|
||||||
|
transition={{ type: "tween" }}
|
||||||
|
/>
|
||||||
|
{/* 进度球 */}
|
||||||
|
<motion.div
|
||||||
|
className={`z-20 absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-primaryHover border-primaryActive border shadow-lg
|
||||||
|
${isHovering || isDragging ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
|
||||||
|
style={{
|
||||||
|
left: `${(currentTime / duration) * 100}%`,
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
/>
|
||||||
|
{/* 预览进度 */}
|
||||||
|
<motion.div
|
||||||
|
className="z-10 absolute h-full bg-primary rounded-full opacity-0 group-hover:opacity-100 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
width: `${(currentTime / duration) * 100}%`,
|
||||||
|
transform: "scaleY(2.5)",
|
||||||
|
transformOrigin: "center",
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { useContext } from "react";
|
||||||
|
import { VideoPlayerContext } from "../VideoPlayer";
|
||||||
|
import { SpeakerWaveIcon, SpeakerXMarkIcon } from "@heroicons/react/24/solid";
|
||||||
|
|
||||||
|
export default function Volume() {
|
||||||
|
const { isMuted, setIsMuted, volume, setVolume, videoRef } =
|
||||||
|
useContext(VideoPlayerContext);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 音量控制 */}
|
||||||
|
<div className="group relative flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMuted(!isMuted)}
|
||||||
|
className="text-white hover:text-primaryHover">
|
||||||
|
{isMuted ? (
|
||||||
|
<SpeakerXMarkIcon className="w-10 h-10" />
|
||||||
|
) : (
|
||||||
|
<SpeakerWaveIcon className="w-10 h-10" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||||
|
<div className="bg-black/80 rounded-lg p-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
value={volume}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newVolume = parseFloat(e.target.value);
|
||||||
|
setVolume(newVolume);
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.volume = newVolume;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-24 w-2 accent-primary-500 [-webkit-appearance:slider-vertical]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
export * from "./Brightness";
|
||||||
|
export * from "./Volume";
|
||||||
|
export * from "./Speed";
|
||||||
|
export * from "./Play";
|
|
@ -1,5 +1,4 @@
|
||||||
import React, { useEffect, useRef, useContext, useState } from "react";
|
import React, { useEffect, useRef, useContext, useState } from "react";
|
||||||
import Hls from "hls.js";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
|
@ -9,10 +8,19 @@ import {
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
ArrowsPointingOutIcon,
|
ArrowsPointingOutIcon,
|
||||||
ArrowsPointingInIcon,
|
ArrowsPointingInIcon,
|
||||||
|
ChevronUpDownIcon,
|
||||||
|
SunIcon,
|
||||||
} from "@heroicons/react/24/solid";
|
} from "@heroicons/react/24/solid";
|
||||||
import { VideoPlayerContext } from "./VideoPlayer";
|
import { VideoPlayerContext } from "./VideoPlayer";
|
||||||
import { formatTime } from "./utlis";
|
import { formatTime } from "./utlis";
|
||||||
import { PlaybackSpeed } from "./type";
|
import { PlaybackSpeed } from "./type";
|
||||||
|
import Volume from "./ControlButtons/Volume";
|
||||||
|
import Brightness from "./ControlButtons/Brightness";
|
||||||
|
import Speed from "./ControlButtons/Speed";
|
||||||
|
import Play from "./ControlButtons/Play";
|
||||||
|
import Setting from "./ControlButtons/Setting";
|
||||||
|
import FullScreen from "./ControlButtons/FullScreen";
|
||||||
|
import TimeLine from "./ControlButtons/TimeLine";
|
||||||
|
|
||||||
export const Controls = () => {
|
export const Controls = () => {
|
||||||
const {
|
const {
|
||||||
|
@ -26,6 +34,8 @@ export const Controls = () => {
|
||||||
isReady,
|
isReady,
|
||||||
setIsReady,
|
setIsReady,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
|
setIsSpeedOpen,
|
||||||
|
isSpeedOpen,
|
||||||
setIsPlaying,
|
setIsPlaying,
|
||||||
|
|
||||||
bufferingState,
|
bufferingState,
|
||||||
|
@ -45,16 +55,12 @@ export const Controls = () => {
|
||||||
isDragging,
|
isDragging,
|
||||||
setIsDragging,
|
setIsDragging,
|
||||||
isHovering,
|
isHovering,
|
||||||
|
isBrightnessOpen,
|
||||||
|
setIsBrightnessOpen,
|
||||||
setIsHovering,
|
setIsHovering,
|
||||||
progressRef,
|
progressRef,
|
||||||
} = useContext(VideoPlayerContext);
|
} = useContext(VideoPlayerContext);
|
||||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
||||||
if (!videoRef.current || !progressRef.current) return;
|
|
||||||
|
|
||||||
const rect = progressRef.current.getBoundingClientRect();
|
|
||||||
const percent = (e.clientX - rect.left) / rect.width;
|
|
||||||
videoRef.current.currentTime = percent * videoRef.current.duration;
|
|
||||||
};
|
|
||||||
// 控制栏显示逻辑
|
// 控制栏显示逻辑
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timer: number;
|
let timer: number;
|
||||||
|
@ -83,190 +89,35 @@ export const Controls = () => {
|
||||||
}}
|
}}
|
||||||
className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||||||
{/* 进度条 */}
|
{/* 进度条 */}
|
||||||
<div
|
<TimeLine></TimeLine>
|
||||||
ref={progressRef}
|
|
||||||
className="relative h-1 mb-4 cursor-pointer group"
|
|
||||||
onClick={handleProgressClick}
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
setIsDragging(true);
|
|
||||||
handleProgressClick(e);
|
|
||||||
}}>
|
|
||||||
{/* 背景条 */}
|
|
||||||
<div className="absolute w-full h-full bg-black/80 rounded-full" />
|
|
||||||
{/* 播放进度 */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute h-full bg-primary-500 rounded-full"
|
|
||||||
style={{
|
|
||||||
width: `${(currentTime / duration) * 100}%`,
|
|
||||||
}}
|
|
||||||
transition={{ type: "tween" }}
|
|
||||||
/>
|
|
||||||
{/* 进度球 */}
|
|
||||||
<motion.div
|
|
||||||
className={`absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3 h-3 rounded-full bg-primary shadow-lg
|
|
||||||
${isHovering || isDragging ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
|
|
||||||
style={{
|
|
||||||
left: `${(currentTime / duration) * 100}%`,
|
|
||||||
}}
|
|
||||||
transition={{ duration: 0.1 }}
|
|
||||||
/>
|
|
||||||
{/* 预览进度 */}
|
|
||||||
<motion.div
|
|
||||||
className="absolute h-full bg-white/30 rounded-full opacity-0 group-hover:opacity-100 pointer-events-none"
|
|
||||||
style={{
|
|
||||||
width: `${(currentTime / duration) * 100}%`,
|
|
||||||
transform: "scaleY(2.5)",
|
|
||||||
transformOrigin: "center",
|
|
||||||
}}
|
|
||||||
transition={{ duration: 0.1 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 控制按钮区域 */}
|
{/* 控制按钮区域 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
{/* 播放/暂停按钮 */}
|
{/* 播放/暂停按钮 */}
|
||||||
<button
|
<Play></Play>
|
||||||
onClick={() =>
|
|
||||||
videoRef.current?.paused
|
|
||||||
? videoRef.current.play()
|
|
||||||
: videoRef.current?.pause()
|
|
||||||
}
|
|
||||||
className="text-white hover:text-primary-400">
|
|
||||||
{isPlaying ? (
|
|
||||||
<PauseIcon className="w-6 h-6" />
|
|
||||||
) : (
|
|
||||||
<PlayIcon className="w-6 h-6" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 音量控制 */}
|
|
||||||
<div className="group relative flex items-center">
|
|
||||||
<button
|
|
||||||
onClick={() => setIsMuted(!isMuted)}
|
|
||||||
className="text-white hover:text-primary-400">
|
|
||||||
{isMuted ? (
|
|
||||||
<SpeakerXMarkIcon className="w-6 h-6" />
|
|
||||||
) : (
|
|
||||||
<SpeakerWaveIcon className="w-6 h-6" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
|
||||||
<div className="bg-black/80 rounded-lg p-2">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="1"
|
|
||||||
step="0.1"
|
|
||||||
value={volume}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newVolume = parseFloat(
|
|
||||||
e.target.value
|
|
||||||
);
|
|
||||||
setVolume(newVolume);
|
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.volume = newVolume;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="h-24 w-2 accent-primary-500 [-webkit-appearance:slider-vertical]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 时间显示 */}
|
{/* 时间显示 */}
|
||||||
{duration && (
|
{duration && (
|
||||||
<span className="text-white text-sm">
|
<span className="text-white text-xl">
|
||||||
{formatTime(currentTime)} / {formatTime(duration)}
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 右侧控制按钮 */}
|
{/* 右侧控制按钮 */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
{/* 设置按钮 */}
|
{/* 音量 */}
|
||||||
<div className="relative">
|
<Volume></Volume>
|
||||||
<button
|
{/* 亮度 */}
|
||||||
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
|
<Brightness></Brightness>
|
||||||
className="text-white hover:text-primary-400">
|
|
||||||
<Cog6ToothIcon className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
{/* 设置菜单 */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{isSettingsOpen && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 10 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: 10 }}
|
|
||||||
className="absolute right-0 bottom-full mb-2 bg-black/90 rounded-lg p-4 min-w-[200px]">
|
|
||||||
{/* 倍速选择 */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<h3 className="text-white text-sm mb-2">
|
|
||||||
播放速度
|
|
||||||
</h3>
|
|
||||||
{[0.5, 0.75, 1.0, 1.25, 1.5, 2.0].map(
|
|
||||||
(speed) => (
|
|
||||||
<button
|
|
||||||
key={speed}
|
|
||||||
onClick={() => {
|
|
||||||
setPlaybackSpeed(
|
|
||||||
speed as PlaybackSpeed
|
|
||||||
);
|
|
||||||
if (videoRef.current) {
|
|
||||||
videoRef.current.playbackRate =
|
|
||||||
speed;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`block w-full text-left px-2 py-1 text-sm ${
|
|
||||||
playbackSpeed === speed
|
|
||||||
? "text-primaryHover"
|
|
||||||
: "text-white"
|
|
||||||
}`}>
|
|
||||||
{speed}x
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 亮度调节 */}
|
{/* 倍速控制 */}
|
||||||
<div className="mb-4">
|
<Speed></Speed>
|
||||||
<h3 className="text-white text-sm mb-2">
|
{/* 设置按钮 */}
|
||||||
亮度
|
<Setting></Setting>
|
||||||
</h3>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0.1"
|
|
||||||
max="2"
|
|
||||||
step="0.1"
|
|
||||||
value={brightness}
|
|
||||||
onChange={(e) =>
|
|
||||||
setBrightness(
|
|
||||||
parseFloat(e.target.value)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="w-full accent-primary-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 全屏按钮 */}
|
{/* 全屏按钮 */}
|
||||||
<button
|
<FullScreen></FullScreen>
|
||||||
onClick={() => {
|
|
||||||
if (document.fullscreenElement) {
|
|
||||||
document.exitFullscreen();
|
|
||||||
} else {
|
|
||||||
videoRef.current?.parentElement?.requestFullscreen();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-white hover:text-primary-400">
|
|
||||||
{document.fullscreenElement ? (
|
|
||||||
<ArrowsPointingInIcon className="w-6 h-6" />
|
|
||||||
) : (
|
|
||||||
<ArrowsPointingOutIcon className="w-6 h-6" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
@ -1,59 +1,37 @@
|
||||||
// VideoPlayer.tsx
|
|
||||||
import React, { useContext, useEffect, useRef, useState } from "react";
|
import React, { useContext, useEffect, useRef, useState } from "react";
|
||||||
import Hls from "hls.js";
|
import Hls from "hls.js";
|
||||||
import { VideoPlayerContext } from "./VideoPlayer";
|
import { VideoPlayerContext } from "./VideoPlayer";
|
||||||
|
|
||||||
interface VideoScreenProps {
|
interface VideoDisplayProps {
|
||||||
autoPlay?: boolean;
|
autoPlay?: boolean;
|
||||||
// className?: string;
|
|
||||||
// qualities?: { label: string; value: string }[];
|
|
||||||
// onQualityChange?: (quality: string) => void;
|
|
||||||
}
|
}
|
||||||
export const VideoScreen: React.FC<VideoScreenProps> = ({
|
export const VideoDisplay: React.FC<VideoDisplayProps> = ({
|
||||||
autoPlay = false,
|
autoPlay = false,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
src,
|
src,
|
||||||
poster,
|
poster,
|
||||||
onError,
|
onError,
|
||||||
showControls,
|
|
||||||
setShowControls,
|
|
||||||
isSettingsOpen,
|
|
||||||
setIsSettingsOpen,
|
|
||||||
playbackSpeed,
|
|
||||||
setPlaybackSpeed,
|
|
||||||
videoRef,
|
videoRef,
|
||||||
isReady,
|
|
||||||
setIsReady,
|
setIsReady,
|
||||||
isPlaying,
|
|
||||||
setIsPlaying,
|
setIsPlaying,
|
||||||
error,
|
|
||||||
setError,
|
setError,
|
||||||
bufferingState,
|
|
||||||
setBufferingState,
|
setBufferingState,
|
||||||
volume,
|
|
||||||
setVolume,
|
|
||||||
isMuted,
|
isMuted,
|
||||||
setIsMuted,
|
|
||||||
loadingProgress,
|
|
||||||
setLoadingProgress,
|
setLoadingProgress,
|
||||||
currentTime,
|
|
||||||
setCurrentTime,
|
setCurrentTime,
|
||||||
duration,
|
|
||||||
setDuration,
|
setDuration,
|
||||||
brightness,
|
brightness,
|
||||||
setBrightness,
|
|
||||||
isDragging,
|
isDragging,
|
||||||
setIsDragging,
|
setIsDragging,
|
||||||
isHovering,
|
|
||||||
setIsHovering,
|
|
||||||
progressRef,
|
progressRef,
|
||||||
|
resolution,
|
||||||
|
setResolutions,
|
||||||
} = useContext(VideoPlayerContext);
|
} = useContext(VideoPlayerContext);
|
||||||
// 处理进度条拖拽
|
|
||||||
|
|
||||||
|
// 处理进度条拖拽
|
||||||
const handleProgressDrag = (e: MouseEvent) => {
|
const handleProgressDrag = (e: MouseEvent) => {
|
||||||
if (!isDragging || !videoRef.current || !progressRef.current) return;
|
if (!isDragging || !videoRef.current || !progressRef.current) return;
|
||||||
|
|
||||||
const rect = progressRef.current.getBoundingClientRect();
|
const rect = progressRef.current.getBoundingClientRect();
|
||||||
const percent = Math.max(
|
const percent = Math.max(
|
||||||
0,
|
0,
|
||||||
|
@ -66,23 +44,19 @@ export const VideoScreen: React.FC<VideoScreenProps> = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleMouseUp = () => setIsDragging(false);
|
const handleMouseUp = () => setIsDragging(false);
|
||||||
const handleMouseMove = (e: MouseEvent) => handleProgressDrag(e);
|
const handleMouseMove = (e: MouseEvent) => handleProgressDrag(e);
|
||||||
|
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
};
|
};
|
||||||
}, [isDragging]);
|
}, [isDragging]);
|
||||||
|
|
||||||
// 添加控制栏组件
|
// 初始化 HLS 和事件监听
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let hls: Hls;
|
let hls: Hls;
|
||||||
|
|
||||||
const initializeHls = async () => {
|
const initializeHls = async () => {
|
||||||
if (!videoRef.current) return;
|
if (!videoRef.current) return;
|
||||||
|
|
||||||
|
@ -96,6 +70,10 @@ export const VideoScreen: React.FC<VideoScreenProps> = ({
|
||||||
if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) {
|
if (videoRef.current.canPlayType("application/vnd.apple.mpegurl")) {
|
||||||
videoRef.current.src = src;
|
videoRef.current.src = src;
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
|
|
||||||
|
// 设置视频时长
|
||||||
|
setDuration(videoRef.current.duration);
|
||||||
|
|
||||||
if (autoPlay) {
|
if (autoPlay) {
|
||||||
try {
|
try {
|
||||||
await videoRef.current.play();
|
await videoRef.current.play();
|
||||||
|
@ -126,6 +104,10 @@ export const VideoScreen: React.FC<VideoScreenProps> = ({
|
||||||
|
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, async () => {
|
hls.on(Hls.Events.MANIFEST_PARSED, async () => {
|
||||||
setIsReady(true);
|
setIsReady(true);
|
||||||
|
|
||||||
|
// 设置视频时长
|
||||||
|
setDuration(videoRef.current?.duration || 0);
|
||||||
|
|
||||||
if (autoPlay && videoRef.current) {
|
if (autoPlay && videoRef.current) {
|
||||||
try {
|
try {
|
||||||
await videoRef.current.play();
|
await videoRef.current.play();
|
||||||
|
@ -139,7 +121,6 @@ export const VideoScreen: React.FC<VideoScreenProps> = ({
|
||||||
hls.on(Hls.Events.BUFFER_APPENDING, () => {
|
hls.on(Hls.Events.BUFFER_APPENDING, () => {
|
||||||
setBufferingState(true);
|
setBufferingState(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
hls.on(Hls.Events.FRAG_BUFFERED, (_, data) => {
|
hls.on(Hls.Events.FRAG_BUFFERED, (_, data) => {
|
||||||
setBufferingState(false);
|
setBufferingState(false);
|
||||||
if (data.stats) {
|
if (data.stats) {
|
||||||
|
@ -148,8 +129,8 @@ export const VideoScreen: React.FC<VideoScreenProps> = ({
|
||||||
setLoadingProgress(Math.round(progress));
|
setLoadingProgress(Math.round(progress));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let networkError;
|
|
||||||
let fatalError;
|
let fatalError;
|
||||||
|
let networkError;
|
||||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
|
@ -176,20 +157,34 @@ export const VideoScreen: React.FC<VideoScreenProps> = ({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
// Event handlers
|
|
||||||
|
// 事件处理
|
||||||
const handlePlay = () => setIsPlaying(true);
|
const handlePlay = () => setIsPlaying(true);
|
||||||
const handlePause = () => setIsPlaying(false);
|
const handlePause = () => setIsPlaying(false);
|
||||||
const handleEnded = () => setIsPlaying(false);
|
const handleEnded = () => setIsPlaying(false);
|
||||||
const handleWaiting = () => setBufferingState(true);
|
const handleWaiting = () => setBufferingState(true);
|
||||||
const handlePlaying = () => setBufferingState(false);
|
const handlePlaying = () => setBufferingState(false);
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
if (videoRef.current) {
|
||||||
|
// 设置视频时长
|
||||||
|
setDuration(videoRef.current.duration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (videoRef.current) {
|
if (videoRef.current) {
|
||||||
videoRef.current.addEventListener("play", handlePlay);
|
videoRef.current.addEventListener("play", handlePlay);
|
||||||
videoRef.current.addEventListener("pause", handlePause);
|
videoRef.current.addEventListener("pause", handlePause);
|
||||||
videoRef.current.addEventListener("ended", handleEnded);
|
videoRef.current.addEventListener("ended", handleEnded);
|
||||||
videoRef.current.addEventListener("waiting", handleWaiting);
|
videoRef.current.addEventListener("waiting", handleWaiting);
|
||||||
videoRef.current.addEventListener("playing", handlePlaying);
|
videoRef.current.addEventListener("playing", handlePlaying);
|
||||||
|
videoRef.current.addEventListener(
|
||||||
|
"loadedmetadata",
|
||||||
|
handleLoadedMetadata
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
initializeHls();
|
initializeHls();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (videoRef.current) {
|
if (videoRef.current) {
|
||||||
videoRef.current.removeEventListener("play", handlePlay);
|
videoRef.current.removeEventListener("play", handlePlay);
|
||||||
|
@ -197,6 +192,10 @@ export const VideoScreen: React.FC<VideoScreenProps> = ({
|
||||||
videoRef.current.removeEventListener("ended", handleEnded);
|
videoRef.current.removeEventListener("ended", handleEnded);
|
||||||
videoRef.current.removeEventListener("waiting", handleWaiting);
|
videoRef.current.removeEventListener("waiting", handleWaiting);
|
||||||
videoRef.current.removeEventListener("playing", handlePlaying);
|
videoRef.current.removeEventListener("playing", handlePlaying);
|
||||||
|
videoRef.current.removeEventListener(
|
||||||
|
"loadedmetadata",
|
||||||
|
handleLoadedMetadata
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (hls) {
|
if (hls) {
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
|
@ -216,7 +215,6 @@ export const VideoScreen: React.FC<VideoScreenProps> = ({
|
||||||
onTimeUpdate={() => {
|
onTimeUpdate={() => {
|
||||||
if (videoRef.current) {
|
if (videoRef.current) {
|
||||||
setCurrentTime(videoRef.current.currentTime);
|
setCurrentTime(videoRef.current.currentTime);
|
||||||
setDuration(videoRef.current.duration);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { createContext, ReactNode, useRef, useState } from "react";
|
import React, { createContext, ReactNode, useRef, useState } from "react";
|
||||||
import { PlaybackSpeed } from "./type";
|
import { PlaybackSpeed } from "./type";
|
||||||
import VideoPlayerLayout from "./VideoPlayerLayout";
|
import VideoPlayerLayout from "./VideoPlayerLayout";
|
||||||
|
import { Resolution } from "./interface";
|
||||||
|
|
||||||
interface VideoPlayerContextType {
|
interface VideoPlayerContextType {
|
||||||
src: string;
|
src: string;
|
||||||
|
@ -38,6 +39,14 @@ interface VideoPlayerContextType {
|
||||||
isHovering: boolean;
|
isHovering: boolean;
|
||||||
setIsHovering: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsHovering: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
progressRef: React.RefObject<HTMLDivElement>;
|
progressRef: React.RefObject<HTMLDivElement>;
|
||||||
|
isSpeedOpen: boolean;
|
||||||
|
setIsSpeedOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
isBrightnessOpen: boolean;
|
||||||
|
setIsBrightnessOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
resolution: number;
|
||||||
|
setResolution: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
resolutions: Resolution[];
|
||||||
|
setResolutions: React.Dispatch<React.SetStateAction<Resolution[]>>;
|
||||||
}
|
}
|
||||||
export const VideoPlayerContext = createContext<VideoPlayerContextType | null>(
|
export const VideoPlayerContext = createContext<VideoPlayerContextType | null>(
|
||||||
null
|
null
|
||||||
|
@ -66,10 +75,13 @@ export function VideoPlayer({
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
const [brightness, setBrightness] = useState(1);
|
const [brightness, setBrightness] = useState(1);
|
||||||
|
const [resolution, setResolution] = useState(-1);
|
||||||
|
const [resolutions, setResolutions] = useState<Resolution[]>([]);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isHovering, setIsHovering] = useState(false);
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
const progressRef = useRef<HTMLDivElement>(null);
|
const progressRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isSpeedOpen, setIsSpeedOpen] = useState(false);
|
||||||
|
const [isBrightnessOpen, setIsBrightnessOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<VideoPlayerContext.Provider
|
<VideoPlayerContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
@ -108,6 +120,14 @@ export function VideoPlayer({
|
||||||
isHovering,
|
isHovering,
|
||||||
setIsHovering,
|
setIsHovering,
|
||||||
progressRef,
|
progressRef,
|
||||||
|
isSpeedOpen,
|
||||||
|
setIsSpeedOpen,
|
||||||
|
isBrightnessOpen,
|
||||||
|
setIsBrightnessOpen,
|
||||||
|
resolution,
|
||||||
|
setResolution,
|
||||||
|
resolutions,
|
||||||
|
setResolutions,
|
||||||
}}>
|
}}>
|
||||||
<VideoPlayerLayout></VideoPlayerLayout>
|
<VideoPlayerLayout></VideoPlayerLayout>
|
||||||
</VideoPlayerContext.Provider>
|
</VideoPlayerContext.Provider>
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { useContext } from "react";
|
||||||
import { VideoPlayerContext } from "./VideoPlayer";
|
import { VideoPlayerContext } from "./VideoPlayer";
|
||||||
import Controls from "./VideoControls";
|
import Controls from "./VideoControls";
|
||||||
import { AnimatePresence } from "framer-motion";
|
import { AnimatePresence } from "framer-motion";
|
||||||
import { VideoScreen } from "./VideoScreen";
|
import { VideoDisplay } from "./VideoDisplay";
|
||||||
import LoadingOverlay from "./LoadingOverlay";
|
import LoadingOverlay from "./LoadingOverlay";
|
||||||
|
|
||||||
export default function VideoPlayerLayout() {
|
export default function VideoPlayerLayout() {
|
||||||
|
@ -17,14 +17,15 @@ export default function VideoPlayerLayout() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`relative aspect-video w-full bg-black rounded-lg overflow-hidden`}
|
className={`relative w-full bg-black rounded-lg overflow-hidden`}
|
||||||
|
style={{ aspectRatio: "21/9" }}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
setIsHovering(true);
|
setIsHovering(true);
|
||||||
setShowControls(true);
|
setShowControls(true);
|
||||||
}}>
|
}}>
|
||||||
{!isReady && <div>123</div>}
|
{!isReady && <div>123</div>}
|
||||||
{!isReady && <LoadingOverlay></LoadingOverlay>}
|
{!isReady && <LoadingOverlay></LoadingOverlay>}
|
||||||
<VideoScreen></VideoScreen>
|
<VideoDisplay></VideoDisplay>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{(showControls || isDragging) && <Controls />}
|
{(showControls || isDragging) && <Controls />}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
// 定义清晰度选项的类型
|
||||||
|
export interface Resolution {
|
||||||
|
id: number;
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
bitrate: number;
|
||||||
|
label: string;
|
||||||
|
url?: string; // 可选:清晰度对应的视频URL
|
||||||
|
active?: boolean; // 可选:是否是当前激活的清晰度
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
export const formatTime = (seconds: number): string => {
|
export const formatTime = (seconds: number = 0): string => {
|
||||||
const minutes = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
const remainingSeconds = Math.floor(seconds % 60);
|
const remainingSeconds = Math.floor(seconds % 60);
|
||||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||||
|
|
|
@ -123,4 +123,4 @@
|
||||||
.custom-table .ant-table-tbody>tr:last-child>td {
|
.custom-table .ant-table-tbody>tr:last-child>td {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
/* 去除最后一行的底部边框 */
|
/* 去除最后一行的底部边框 */
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue