Commit 8aa270b1 authored by kingweight's avatar kingweight

视频帧轨道

parent da662a60
......@@ -2,17 +2,10 @@
<div class="model-content">
<!-- <div
id="drop-area"
(dragover)="onDragOver($event)"
(drop)="onDrop($event)"
>
拖拽文件到此区域进行批量上传
</div> -->
<div style="width: 100%; display: inline-flex;flex-direction: row;flex-wrap: wrap;">
<div style="width: 50% ; margin-left: 2.5%; min-width: 300px; max-width: 650px; margin-top: 20px;border: 2px dashed;padding: 20px 10px 5px 10px;"
align='center' >
<div style="width: 100px; background-color: white; transform: translateY(-30px);" > 视频 </div>
<div style="width: 100px; background-color: white; transform: translateY(-30px);" > 视频互动课信息 </div>
<div style="margin: 20px; display: flex; align-items: center; ">
<h3 style="width: 120px;">单元名称:</h3>
......@@ -27,11 +20,10 @@
<app-upload-image-with-preview style="width: 80%;" [picUrl]="item.cover" (imageUploaded)="onImageUploadSuccess($event)">
</app-upload-image-with-preview>
<app-upload-video [videoUrl]="item.video_url.replace('.mp4', '_h.mp4')" [showUploadBtn]="true" (videoUploaded)="onVideoUploaded($event)" (onCapture)="handleOnCapture($event)" (parentRef)="onParentRef($event)"></app-upload-video>
<div style="padding: 5px 0;" align='center'>
<!-- <div style="padding: 5px 0;" align='center'>
<h3>视频地址</h3>
<input type="text" nz-input [(ngModel)]="item.video_url" disabled (blur)="save()">
</div>
<!-- <button style="margin-top: 10px; " (click)="onRemoveVideo()">删除视频</button> -->
</div> -->
</div>
</div>
</div>
......@@ -210,88 +202,6 @@
</div>
<!-- <div style="margin-top:10px; border: 1px #ccc solid; border-radius: 15px; padding: 15px; background-color: rgb(248, 255, 245);">
<h3>设置选项:</h3>
<div style="margin: 10px;">
<div style="display: flex; align-items: center; justify-content:flex-start;">
<span style="margin-right: 20px;">选项类型:</span>
<label style="" nz-checkbox [(ngModel)]="ques.optionTypeText" (ngModelChange)="checkboxChange($event, ques, 'optionTypeText')">文本</label>
<label style="" nz-checkbox [(ngModel)]="ques.optionTypeAudio" (ngModelChange)="checkboxChange($event, ques, 'optionTypeAudio')">音频</label>
<label style="" nz-checkbox [(ngModel)]="ques.optionTypePic" (ngModelChange)="checkboxChange($event, ques, 'optionTypePic')">图片</label>
</div>
</div>
<div *ngFor="let option of ques.optionArr; let j = index"
style="border: 1px #ccc solid; border-radius: 15px; width: 100%; padding: 30px; ">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center;">
<h4>设置选项-{{j+1}}:</h4>
<div align="center" style="display: flex; align-items: center; justify-content: center;margin-top: -7px;">
<nz-radio-group [ngModel]="option.answer" (ngModelChange)="radioChange($event, option, 'answer')">
<div style="display: flex; flex-wrap: wrap; width: 100%;">
<label style="margin-left: 5px;" nz-radio [nzValue]="'isRight'">{{'正确'}}</label>
<label style="margin-left: 5px;" nz-radio [nzValue]="'isWrong'">{{'错误'}}</label>
</div>
</nz-radio-group>
</div>
</div>
<button style="float:right" nz-button nzType="danger" (click)="deleteOptionBtnClick(ques.optionArr, j)">
x
</button>
</div>
<div *ngIf="ques.optionTypeText" style="margin-top: 5px; display: flex; align-items: center; ">
<span style="">文本:</span>
<input type="text" nz-input [(ngModel)]="option.text" (blur)="save()" style=" width: 200px;">
</div>
<div *ngIf="ques.optionTypeAudio" style="margin-top: 10px; display: flex; align-items: center;">
<span style="">音频:</span>
<app-audio-recorder [audioUrl]="option.audio_url"
(audioUploaded)="onItemAudioUploadSuccess($event, option, 'audio_url')">
</app-audio-recorder>
</div>
<div *ngIf="ques.optionTypePic" style="margin-top: 10px; display: flex; ">
<span style="">图片:</span>
<div style="width: 300px;">
<app-upload-image-with-preview [picUrl]="option.pic_url"
(imageUploaded)="onItemImgUploadSuccess($event, option, 'pic_url')">
</app-upload-image-with-preview>
</div>
<button *ngIf="option.pic_url" style="float: right;" nz-button nzType="" (click)="onDeletaImg(option, 'pic_url')">
x
</button>
</div>
</div>
</div> -->
<!-- <div style="margin-top: 30px">
<button nz-button nzType="dashed" (click)="addOneOption(ques)" style="width: 150px; height: 50px;">
<i nz-icon nzType="plus-circle" nzTheme="outline"></i>
添加选项
</button>
</div> -->
</div>
<div style="padding: 30px;">
......@@ -315,11 +225,13 @@
<div style="margin-bottom: 20px; text-align: center;">
<video
id="fragment-video"
[src]="fragmentVideoUrl"
[src]="item.video_url.replace('.mp4', '_h.mp4')"
crossorigin="anonymous"
controls
style="width: 100%; max-width: 800px;"
(loadedmetadata)="onFragmentVideoLoadedMetadata($event)"
(timeupdate)="onFragmentVideoTimeUpdate($event)">
(timeupdate)="onFragmentVideoTimeUpdate($event)"
(error)="onFragmentVideoError($event)">
您的浏览器不支持视频播放
</video>
</div>
......@@ -332,10 +244,11 @@
</div>
<!-- 视频帧缩略图轨道 -->
<div *ngIf="videoThumbnails.length > 0" style="margin-bottom: 15px;">
<div style="display: flex; overflow-x: auto; padding: 10px 0; border: 1px solid #e8e8e8; border-radius: 4px; background-color: #fafafa;">
<div *ngIf="videoThumbnails.length > 0" style="margin-bottom: 45px;">
<div #thumbnailContainer style="display: flex; overflow-x: auto; padding: 10px 0; border: 1px solid #e8e8e8; border-radius: 4px; background-color: #fafafa;">
<div
*ngFor="let thumbnail of videoThumbnails; let i = index"
[id]="'thumbnail-' + i"
(click)="onThumbnailClick(getThumbnailTime(i))"
[style.border]="isThumbnailActive(i) ? '2px solid #1890ff' : '2px solid transparent'"
style="flex-shrink: 0; margin: 0 2px; cursor: pointer; border-radius: 2px; transition: border 0.2s;">
......
import { Component, EventEmitter, Input, OnDestroy, OnChanges, OnInit, Output, ApplicationRef, ChangeDetectorRef } from '@angular/core';
import { Component, EventEmitter, Input, OnDestroy, OnChanges, OnInit, Output, ApplicationRef, ChangeDetectorRef, ViewChild, ElementRef } from '@angular/core';
import { JsonPipe } from '@angular/common';
import { NzMessageService } from 'ng-zorro-antd/message';
import { UploadChangeParam, UploadXHRArgs } from 'ng-zorro-antd/upload';
......@@ -14,6 +14,7 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
// 储存数据用
saveKey = "JJ_Vedio_Practice";
thumbnailCacheKey = "JJ_Video_Thumbnail_Cache"; // localStorage 缓存键名
baseData;
......@@ -23,6 +24,7 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
cover: "",
title: "",
video_title: "",
quesArr: []
};
//临时储存对象
tempItem;
......@@ -51,6 +53,8 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
videoThumbnails: string[] = []; // 视频帧缩略图数组
isGeneratingThumbnails = false; // 是否正在生成缩略图
thumbnailCount = 20; // 生成的缩略图数量
thumbnailCache: Map<string, { thumbnails: string[], duration: number, count: number }> = new Map(); // 缩略图缓存
@ViewChild('thumbnailContainer', { static: false }) thumbnailContainer: ElementRef; // 缩略图容器引用
......@@ -142,6 +146,9 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
ngOnInit() {
// 从 localStorage 加载缩略图缓存
this.loadThumbnailCacheFromLocalStorage();
// 获取存储的数据
(<any>window).courseware.getData((data) => {
......@@ -167,20 +174,53 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
this.currentFragmentQues = ques;
this.currentFragmentIndex = i;
this.fragmentVideoUrl = this.item.video_url || '';
const videoUrl = this.item.video_url ? this.item.video_url.replace('.mp4', '_h.mp4') : '';
this.fragmentVideoCurrentTime = ques.time || 0;
this.fragmentSelectedTime = ques.time || 0;
this.videoThumbnails = [];
this.isFragmentModalVisible = true;
// 等待弹窗渲染后获取视频元素并生成缩略图
// 检查缓存
const cachedData = this.thumbnailCache.get(videoUrl);
if (cachedData) {
console.log('使用缓存的缩略图');
this.videoThumbnails = cachedData.thumbnails;
this.fragmentVideoDuration = cachedData.duration;
this.thumbnailCount = cachedData.count;
this.changeDetectorRef.detectChanges();
}
// 等待弹窗渲染后获取视频元素
setTimeout(() => {
this.fragmentVideoElement = document.getElementById('fragment-video') as HTMLVideoElement;
if (this.fragmentVideoElement) {
if (this.fragmentSelectedTime > 0) {
this.fragmentVideoElement.currentTime = this.fragmentSelectedTime;
// 确保设置 crossOrigin 属性(必须在设置 src 之前或重新加载时设置)
if (!this.fragmentVideoElement.crossOrigin) {
this.fragmentVideoElement.crossOrigin = 'anonymous';
}
// 如果视频已经加载完成,获取时长并生成缩略图
if (this.fragmentVideoElement.readyState >= 2) {
this.fragmentVideoDuration = this.fragmentVideoElement.duration || 0;
if (this.fragmentSelectedTime > 0) {
this.fragmentVideoElement.currentTime = this.fragmentSelectedTime;
}
// 如果缓存不存在,生成缩略图
if (this.fragmentVideoDuration > 0 && !cachedData) {
setTimeout(() => {
this.generateVideoThumbnails();
}, 100);
}
} else {
// 视频未加载,等待 loadedmetadata 事件
if (this.fragmentSelectedTime > 0) {
this.fragmentVideoElement.currentTime = this.fragmentSelectedTime;
}
// 如果缓存不存在,等待加载完成后生成
if (!cachedData) {
// 将在 onFragmentVideoLoadedMetadata 中生成
}
}
// 生成视频帧缩略图
this.generateVideoThumbnails();
}
}, 100);
}
......@@ -195,15 +235,160 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
this.fragmentSelectedTime = 0;
this.fragmentVideoElement = null;
this.isDraggingTrack = false;
this.videoThumbnails = [];
this.videoThumbnails = []; // 只清除当前显示的缩略图,保留缓存
this.isGeneratingThumbnails = false;
}
// 清除指定视频的缩略图缓存
clearThumbnailCache(videoUrl?: string) {
if (videoUrl) {
this.thumbnailCache.delete(videoUrl);
// 同时从 localStorage 清除
this.removeThumbnailCacheFromLocalStorage(videoUrl);
console.log('已清除缓存:', videoUrl);
} else {
// 清除所有缓存
this.thumbnailCache.clear();
// 同时清除 localStorage
this.clearThumbnailCacheFromLocalStorage();
console.log('已清除所有缩略图缓存');
}
}
// 保存缩略图缓存到 localStorage
saveThumbnailCacheToLocalStorage(videoUrl: string, cacheData: { thumbnails: string[], duration: number, count: number }) {
try {
// 获取现有的缓存
const existingCache = this.getThumbnailCacheFromLocalStorage();
// 更新或添加新的缓存项
existingCache[videoUrl] = cacheData;
// 限制存储的视频数量,避免超出 localStorage 大小限制(通常 5-10MB)
const maxCachedVideos = 10; // 最多缓存10个视频的缩略图
const cacheEntries = Object.keys(existingCache);
if (cacheEntries.length > maxCachedVideos) {
// 删除最旧的缓存(简单策略:删除第一个)
const oldestKey = cacheEntries[0];
delete existingCache[oldestKey];
console.log('缓存已满,删除最旧的缓存:', oldestKey);
}
// 尝试保存到 localStorage
const cacheJson = JSON.stringify(existingCache);
const cacheSize = new Blob([cacheJson]).size;
const maxSize = 4 * 1024 * 1024; // 4MB 限制,留一些余量
if (cacheSize > maxSize) {
console.warn('缩略图缓存大小超出限制,跳过保存到 localStorage');
return;
}
localStorage.setItem(this.thumbnailCacheKey, cacheJson);
console.log('缩略图缓存已保存到 localStorage,大小:', (cacheSize / 1024).toFixed(2), 'KB');
} catch (error) {
console.error('保存缩略图缓存到 localStorage 失败:', error);
// 如果存储失败(如超出大小限制),尝试清理一些旧缓存
if (error.name === 'QuotaExceededError') {
console.warn('localStorage 空间不足,尝试清理旧缓存');
this.clearOldThumbnailCache();
}
}
}
// 从 localStorage 加载缩略图缓存
loadThumbnailCacheFromLocalStorage() {
try {
const cacheJson = localStorage.getItem(this.thumbnailCacheKey);
if (!cacheJson) {
return;
}
const cacheData = JSON.parse(cacheJson);
// 将对象转换为 Map
for (const [videoUrl, data] of Object.entries(cacheData)) {
this.thumbnailCache.set(videoUrl, data as { thumbnails: string[], duration: number, count: number });
}
console.log('从 localStorage 加载缩略图缓存,视频数量:', this.thumbnailCache.size);
} catch (error) {
console.error('从 localStorage 加载缩略图缓存失败:', error);
// 如果解析失败,清除损坏的缓存
localStorage.removeItem(this.thumbnailCacheKey);
}
}
// 从 localStorage 获取所有缓存(返回对象格式)
getThumbnailCacheFromLocalStorage(): { [key: string]: { thumbnails: string[], duration: number, count: number } } {
try {
const cacheJson = localStorage.getItem(this.thumbnailCacheKey);
if (!cacheJson) {
return {};
}
return JSON.parse(cacheJson);
} catch (error) {
console.error('读取 localStorage 缓存失败:', error);
return {};
}
}
// 从 localStorage 删除指定视频的缓存
removeThumbnailCacheFromLocalStorage(videoUrl: string) {
try {
const existingCache = this.getThumbnailCacheFromLocalStorage();
delete existingCache[videoUrl];
localStorage.setItem(this.thumbnailCacheKey, JSON.stringify(existingCache));
} catch (error) {
console.error('从 localStorage 删除缓存失败:', error);
}
}
// 清除 localStorage 中的所有缩略图缓存
clearThumbnailCacheFromLocalStorage() {
try {
localStorage.removeItem(this.thumbnailCacheKey);
} catch (error) {
console.error('清除 localStorage 缓存失败:', error);
}
}
// 清理旧的缩略图缓存(保留最新的几个)
clearOldThumbnailCache() {
try {
const existingCache = this.getThumbnailCacheFromLocalStorage();
const cacheEntries = Object.keys(existingCache);
// 只保留最新的3个缓存
if (cacheEntries.length > 3) {
const entriesToKeep = cacheEntries.slice(-3);
const newCache: { [key: string]: any } = {};
entriesToKeep.forEach(key => {
newCache[key] = existingCache[key];
});
localStorage.setItem(this.thumbnailCacheKey, JSON.stringify(newCache));
console.log('已清理旧缓存,保留最新的', entriesToKeep.length, '');
}
} catch (error) {
console.error('清理旧缓存失败:', error);
}
}
// 视频片段弹窗 - 确认
fragmentModalOk() {
if (this.currentFragmentQues) {
// 保存选中的时间
this.currentFragmentQues.time = this.fragmentSelectedTime;
this.save();
// 触发变更检测,确保分钟和秒输入框更新
this.changeDetectorRef.detectChanges();
// 在下一个变更检测周期再次触发,确保视图完全更新
setTimeout(() => {
this.changeDetectorRef.detectChanges();
}, 0);
}
this.fragmentModalCancel();
}
......@@ -211,11 +396,44 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
// 视频加载元数据
onFragmentVideoLoadedMetadata(event) {
const video = event.target;
// 确保设置 crossOrigin 属性
if (!video.crossOrigin) {
video.crossOrigin = 'anonymous';
}
this.fragmentVideoDuration = video.duration || 0;
this.fragmentVideoElement = video;
if (this.fragmentSelectedTime > 0) {
video.currentTime = this.fragmentSelectedTime;
}
// 检查缓存
const videoUrl = this.item.video_url ? this.item.video_url.replace('.mp4', '_h.mp4') : '';
const cachedData = this.thumbnailCache.get(videoUrl);
// 视频元数据加载完成后,如果缓存不存在则生成缩略图
if (this.fragmentVideoDuration > 0 && this.isFragmentModalVisible && !cachedData) {
// 延迟一下确保视频完全准备好
setTimeout(() => {
this.generateVideoThumbnails();
}, 200);
} else if (cachedData && this.videoThumbnails.length === 0) {
// 如果缓存存在但缩略图数组为空,从缓存恢复
this.videoThumbnails = cachedData.thumbnails;
this.changeDetectorRef.detectChanges();
}
}
// 视频加载错误处理
onFragmentVideoError(event) {
console.error('视频加载错误:', event);
const video = event.target;
if (video.error) {
console.error('视频错误代码:', video.error.code, '错误信息:', video.error.message);
// 如果是因为跨域问题,尝试重新设置
if (video.error.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED) {
this.message.create('warning', '视频加载失败,可能是跨域问题。请确保视频服务器支持 CORS。');
}
}
}
// 视频时间更新
......@@ -223,7 +441,13 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
if (!this.isDraggingTrack) {
const video = event.target;
this.fragmentVideoCurrentTime = video.currentTime;
const previousSelectedIndex = Math.floor(this.fragmentSelectedTime);
this.fragmentSelectedTime = video.currentTime;
// 只有当时间跨越到新的缩略图索引时才滚动(避免频繁滚动)
const currentSelectedIndex = Math.floor(this.fragmentSelectedTime);
if (currentSelectedIndex !== previousSelectedIndex) {
this.scrollToActiveThumbnail();
}
}
}
......@@ -238,6 +462,8 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
if (this.fragmentVideoElement) {
this.fragmentVideoElement.currentTime = value;
}
// 滚动到对应的缩略图
this.scrollToActiveThumbnail();
}
// 轨道拖动结束
......@@ -260,7 +486,28 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
// 生成视频帧缩略图
async generateVideoThumbnails() {
if (!this.fragmentVideoElement || !this.fragmentVideoDuration) {
if (!this.fragmentVideoElement) {
return;
}
// 如果视频时长还没有获取到,尝试从视频元素获取
if (!this.fragmentVideoDuration && this.fragmentVideoElement.duration) {
this.fragmentVideoDuration = this.fragmentVideoElement.duration;
}
if (!this.fragmentVideoDuration || this.fragmentVideoDuration <= 0) {
console.warn('视频时长未获取到,无法生成缩略图');
return;
}
// 检查缓存
const videoUrl = this.item.video_url ? this.item.video_url.replace('.mp4', '_h.mp4') : '';
const cachedData = this.thumbnailCache.get(videoUrl);
if (cachedData && cachedData.thumbnails.length > 0) {
console.log('使用缓存的缩略图,跳过生成');
this.videoThumbnails = cachedData.thumbnails;
this.thumbnailCount = cachedData.count;
this.changeDetectorRef.detectChanges();
return;
}
......@@ -268,11 +515,40 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
this.videoThumbnails = [];
const video = this.fragmentVideoElement;
// 确保设置 crossOrigin 属性
if (!video.crossOrigin) {
video.crossOrigin = 'anonymous';
// 如果视频已经加载,需要重新加载
if (video.readyState >= 2) {
const currentSrc = video.src;
video.src = '';
video.crossOrigin = 'anonymous';
video.src = currentSrc;
// 等待视频重新加载
await new Promise((resolve, reject) => {
const onLoadedMetadata = () => {
video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('error', onError);
resolve(null);
};
const onError = () => {
video.removeEventListener('loadedmetadata', onLoadedMetadata);
video.removeEventListener('error', onError);
reject(new Error('视频重新加载失败'));
};
video.addEventListener('loadedmetadata', onLoadedMetadata);
video.addEventListener('error', onError);
});
}
}
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 160; // 缩略图宽度
canvas.height = 90; // 缩略图高度 (16:9 比例)
this.thumbnailCount = Math.round(this.fragmentVideoDuration);
const interval = this.fragmentVideoDuration / this.thumbnailCount;
const originalTime = video.currentTime;
const originalPaused = video.paused;
......@@ -283,22 +559,33 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
}
// 逐个提取帧
for (let i = 0; i < this.thumbnailCount; i++) {
const time = i * interval;
await this.seekToTime(video, time);
// 等待视频帧加载
await new Promise(resolve => setTimeout(resolve, 100));
// 绘制当前帧到 canvas
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// 转换为 base64 图片
const thumbnail = canvas.toDataURL('image/jpeg', 0.7);
this.videoThumbnails.push(thumbnail);
// 更新视图
this.changeDetectorRef.detectChanges();
try {
for (let i = 0; i < this.thumbnailCount; i++) {
const time = i * interval;
await this.seekToTime(video, time);
// 等待视频帧加载
await new Promise(resolve => setTimeout(resolve, 100));
// 绘制当前帧到 canvas
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
// 转换为 base64 图片,添加错误处理
try {
const thumbnail = canvas.toDataURL('image/jpeg', 0.7);
this.videoThumbnails.push(thumbnail);
} catch (error) {
console.error('生成缩略图失败 (可能是跨域问题):', error);
// 如果跨域问题无法解决,使用占位符
this.videoThumbnails.push('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYwIiBoZWlnaHQ9IjkwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxNjAiIGhlaWdodD0iOTAiIGZpbGw9IiNlZWUiLz48dGV4dCB4PSI1MCUiIHk9IjUwJSIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPuaXoOazleWKoOi9veWbvueJhzwvdGV4dD48L3N2Zz4=');
}
// 更新视图
this.changeDetectorRef.detectChanges();
}
} catch (error) {
console.error('生成缩略图过程中出错:', error);
this.message.create('error', '生成缩略图失败,可能是跨域问题。请确保视频服务器支持 CORS。');
}
// 恢复视频状态
......@@ -308,6 +595,20 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
await this.seekToTime(video, originalTime);
}
// 将生成的缩略图存入缓存
if (this.videoThumbnails.length > 0) {
const videoUrl = this.item.video_url ? this.item.video_url.replace('.mp4', '_h.mp4') : '';
const cacheData = {
thumbnails: [...this.videoThumbnails], // 创建副本避免引用问题
duration: this.fragmentVideoDuration,
count: this.thumbnailCount
};
this.thumbnailCache.set(videoUrl, cacheData);
// 同时保存到 localStorage
this.saveThumbnailCacheToLocalStorage(videoUrl, cacheData);
console.log('缩略图已缓存,视频URL:', videoUrl, '缩略图数量:', this.videoThumbnails.length);
}
this.isGeneratingThumbnails = false;
this.changeDetectorRef.detectChanges();
}
......@@ -330,29 +631,73 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
if (this.fragmentVideoElement) {
this.fragmentVideoElement.currentTime = time;
}
// 点击时也滚动到该缩略图(虽然已经在可视区域,但可以确保居中)
setTimeout(() => {
this.scrollToActiveThumbnail();
}, 100);
}
// 滚动到激活的缩略图位置(居中显示)
scrollToActiveThumbnail() {
if (!this.thumbnailContainer || !this.fragmentVideoDuration || this.thumbnailCount <= 0) {
return;
}
const container = this.thumbnailContainer.nativeElement;
const selectedIndex = Math.floor(this.fragmentSelectedTime);
// 确保索引在有效范围内
if (selectedIndex < 0 || selectedIndex >= this.videoThumbnails.length) {
return;
}
const thumbnailElement = document.getElementById(`thumbnail-${selectedIndex}`);
if (!thumbnailElement) {
return;
}
// 计算滚动位置,使缩略图居中
const containerRect = container.getBoundingClientRect();
const thumbnailRect = thumbnailElement.getBoundingClientRect();
const containerScrollLeft = container.scrollLeft;
const containerWidth = containerRect.width;
const thumbnailWidth = thumbnailRect.width;
const thumbnailLeft = thumbnailElement.offsetLeft;
// 计算目标滚动位置:缩略图中心对齐容器中心
const targetScrollLeft = thumbnailLeft - (containerWidth / 2) + (thumbnailWidth / 2);
// 平滑滚动
container.scrollTo({
left: targetScrollLeft,
behavior: 'smooth'
});
}
// 获取缩略图对应的时间
getThumbnailTime(index: number): number {
if (!this.fragmentVideoDuration) {
if (!this.fragmentVideoDuration || this.thumbnailCount <= 0) {
return 0;
}
if (index >= this.thumbnailCount) {
return this.fragmentVideoDuration;
}
return (index * this.fragmentVideoDuration) / this.thumbnailCount;
// 使用与生成缩略图时相同的计算方式:每秒一张图
// 索引0对应0秒,索引1对应1秒,索引2对应2秒...
return index;
}
// 判断缩略图是否处于激活状态(当前时间在该帧范围内)
isThumbnailActive(index: number): boolean {
if (!this.fragmentVideoDuration) {
if (!this.fragmentVideoDuration || this.thumbnailCount <= 0) {
return false;
}
const currentTime = this.getThumbnailTime(index);
const nextTime = index < this.thumbnailCount - 1
? this.getThumbnailTime(index + 1)
: this.fragmentVideoDuration;
return this.fragmentSelectedTime >= currentTime && this.fragmentSelectedTime < nextTime;
const thumbnailTime = this.getThumbnailTime(index);
// 由于每秒一张图,每个缩略图代表1秒的时间范围
// 索引i代表时间[i, i+1)秒,最后一个索引代表[lastIndex, duration]秒
// 使用向下取整来判断当前时间属于哪个缩略图
const selectedIndex = Math.floor(this.fragmentSelectedTime);
return selectedIndex === index;
}
initItem() {
......@@ -759,13 +1104,11 @@ export class FormComponent extends ComponentBase implements OnInit, OnChanges, O
}
/**
* 删除视频
* 删除卡片
*/
onRemoveVideo() {
// 清除视频URL和封面,使用户可以重新上传视频
this.item.video_url = "";
this.item.cover = "";
this.save();
}
/** 添加卡片 */
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment