Commit 872ecb5e authored by JinWei's avatar JinWei

Merge branch 'feature-jj_vedio_practice' into 'master'

Feature jj vedio practice

See merge request !2
parents 26c9c782 f4153f61
...@@ -3858,6 +3858,11 @@ ...@@ -3858,6 +3858,11 @@
"randomfill": "^1.0.4" "randomfill": "^1.0.4"
} }
}, },
"crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
},
"css": { "css": {
"version": "2.2.4", "version": "2.2.4",
"resolved": "https://registry.npmjs.org/css/-/css-2.2.4.tgz", "resolved": "https://registry.npmjs.org/css/-/css-2.2.4.tgz",
......
...@@ -36,7 +36,8 @@ ...@@ -36,7 +36,8 @@
"spark-md5": "^3.0.0", "spark-md5": "^3.0.0",
"tslib": "^1.10.0", "tslib": "^1.10.0",
"zone.js": "~0.10.2", "zone.js": "~0.10.2",
"querystring": "^0.2.0" "querystring": "^0.2.0",
"crypto-js": "^4.1.1"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "~0.900.3", "@angular-devkit/build-angular": "~0.900.3",
......
...@@ -25,7 +25,7 @@ export class UploadImageWithPreviewComponent implements OnDestroy, OnChanges { ...@@ -25,7 +25,7 @@ export class UploadImageWithPreviewComponent implements OnDestroy, OnChanges {
@Input() @Input()
iconSize = 2; iconSize = 2;
@Input() @Input()
TIP = '点击上传图片'; TIP = '点击上传视频封面';
@Input() @Input()
disableUpload = false; disableUpload = false;
......
<div class="p-video-box"> <div class="p-video-box">
<div class="up-video" style="display: flex;"> <div class="up-video" style="display: flex;">
<!--<nz-upload class="" [nzDisabled]="!showUploadBtn"--> <nz-upload class="p-image-uploader" [nzDisabled]="uploading" [nzShowUploadList]="false" nzAccept=".mp4"
<!--[nzShowUploadList]="false"--> (nzChange)="handleChange($event)" [nzCustomRequest]="customReq"
<!--[nzBeforeUpload]="beforeUpload"-->
<!--nzAccept = ".mp4"-->
<!--style="margin-right: 1rem">-->
<nz-upload class="p-image-uploader" [nzDisabled]="uploading"
[nzShowUploadList]="false"
nzAccept = ".mp4"
[nzAction]="uploadUrl"
[nzData]="uploadData"
(nzChange)="handleChange($event)"
style="margin-right: 1rem"> style="margin-right: 1rem">
<button type="button" nz-button nzType="default" *ngIf="showUploadBtn" [disabled]="uploading" <button type="button" nz-button nzType="default" *ngIf="showUploadBtn" [disabled]="uploading"
[nzLoading]="uploading" > [nzLoading]="uploading">
<i nz-icon nzType="plus" nzTheme="outline"></i> <i nz-icon nzType="plus" nzTheme="outline"></i>
<span>{{ uploading ? 'Uploading' : 'Select Video' }}</span> <span>{{ uploading ? 'Uploading' : 'Select Video' }}</span>
<!--<span>Select Video</span>-->
</button> </button>
</nz-upload> </nz-upload>
<!--<button nz-button nzType="primary" *ngIf="showUploadBtn"--> <button type="button" nz-button nzType="default" *ngIf="showCapture && !uploading" [disabled]="uploading"
<!--style="margin-right: 1rem"--> (click)="captureVideo()"
<!--type="button"--> [nzLoading]="uploading">
<!--[nzLoading]="uploading"--> <i nz-icon nzType="camera" nzTheme="outline"></i>
<!--(click)="handleUpload()"--> <span>截图</span>
<!--[disabled]="!videoFile || uploading">--> </button>
<!--<i nz-icon type="upload" theme="outline"></i>-->
<!--<span>{{ uploading ? 'Uploading' : 'Start Upload' }}</span>-->
<!--</button>-->
<!--
<button type="button"
(click)="extraCover()"
nz-button nzType="default"
[disabled]=" !(videoUrl && videoUrl.constructor.name === 'String')">设置封面</button>
-->
<!--
<nz-divider></nz-divider>
<div [style.display]="!uploading?'none':''" style="position:relative">
<div style="width: calc( 100% - 20px);">
<nz-progress [nzPercent]="progress">
</nz-progress>
</div>
<i (click)="cancelUpload()" class="anticon anticon-close-circle" style="position: absolute;top: 50%;right: 0;transform: translateY(-50%);cursor: pointer"></i>
</div> </div>
--> <div class="p-box d-flex align-items-center p-video-uploader">
</div>
<div class="p-box d-flex align-items-center p-video-uploader">
<div class="p-upload-icon" *ngIf="!showUploadBtn && !videoUrl && !uploading"> <div class="p-upload-icon" *ngIf="!showUploadBtn && !videoUrl && !uploading">
<i nz-icon nzType="upload" nzTheme="outline"></i> <i nz-icon nzType="upload" nzTheme="outline"></i>
<div class="m-3"></div> <div class="m-3"></div>
...@@ -65,12 +26,11 @@ ...@@ -65,12 +26,11 @@
<div class="p-progress-value">{{progress}}%</div> <div class="p-progress-value">{{progress}}%</div>
</div> </div>
</div> </div>
<div class="p-upload-progress-bg dddd " *ngIf="uploading" <div class="p-upload-progress-bg" *ngIf="uploading" [ngClass]="{'smart-bar': showUploadBtn}">
[ngClass]="{'smart-bar': showUploadBtn}" >
<div class="i-bg" [style.width]="progress+'%'"></div> <div class="i-bg" [style.width]="progress+'%'"></div>
<div class="i-text"> <div class="i-text">
<fa-icon icon="cloud-upload-alt"></fa-icon> <fa-icon icon="cloud-upload-alt"></fa-icon>
Uploading... 上传中,请耐心等待...
</div> </div>
</div> </div>
<div class="p-upload-check-bg" *ngIf="checking"> <div class="p-upload-check-bg" *ngIf="checking">
...@@ -80,15 +40,12 @@ ...@@ -80,15 +40,12 @@
<i nz-icon nzType="loading" nzTheme="outline"></i>Checking... <i nz-icon nzType="loading" nzTheme="outline"></i>Checking...
</div> </div>
</div> </div>
<div class="p-preview" *ngIf="!uploading && videoUrl " > <div class="p-preview" *ngIf="!uploading&&playVedioUrl">
<!--<video crossorigin="anonymous" [src]="videoUrl" controls #videoNode></video>--> <video crossorigin="anonymous" [src]="playVedioUrl" controls #videoNode></video>
<video [src]="safeVideoUrl(videoUrl)" controls #videoNode></video> <!-- <video crossorigin="anonymous" [src]="safeVideoUrl()" controls #videoNode></video> -->
</div> </div>
</div> </div>
<div [style.display]="!checkVideoExists?'none':''"> <div [style.display]="!checkVideoExists?'none':''">
<span><i nz-icon nzType="loading" nzTheme="outline"></i> checking file to upload</span> <span><i nz-icon nzType="loading" nzTheme="outline"></i> checking file to upload</span>
</div>
</div> </div>
</div>
import {Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, SecurityContext, ViewChild} from '@angular/core'; import {Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output, SecurityContext, ViewChild} from '@angular/core';
import {NzMessageService, UploadChangeParam, UploadFile, UploadFileStatus} from 'ng-zorro-antd';
import {HttpClientModule} from '@angular/common/http';
import {HttpClient, HttpHeaders, HttpRequest, HttpEvent, HttpEventType, HttpResponse, HttpParams} from "@angular/common/http";
import {NzMessageService, UploadChangeParam, UploadFile, UploadFileStatus, UploadXHRArgs} from 'ng-zorro-antd';
import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser'; import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser';
import cryptoJs from 'crypto-js';
import {v4} from 'uuid';
function httpHeadCall(requsetUrl, callback) {
let xhr = new XMLHttpRequest();
try {
xhr.onreadystatechange = () => {
try {
if (xhr.readyState == 4) {
if ((xhr.status >= 200 && xhr.status < 400)) {
callback(true);
} else {
callback(false);
}
}
} catch (e) {
callback(false);
}
};
xhr.open("HEAD", requsetUrl, true);
xhr.send();
xhr.timeout = 15000;
xhr.onerror = (e) => {
callback(false);
};
xhr.ontimeout = (e) => {
callback(false);
};
} catch (e) {
callback(false);
}
}
@Component({ @Component({
...@@ -8,7 +44,7 @@ import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser'; ...@@ -8,7 +44,7 @@ import {DomSanitizer, SafeResourceUrl} from '@angular/platform-browser';
templateUrl: './upload-video.component.html', templateUrl: './upload-video.component.html',
styleUrls: ['./upload-video.component.scss'] styleUrls: ['./upload-video.component.scss']
}) })
export class UploadVideoComponent implements OnChanges, OnDestroy { export class UploadVideoComponent implements OnChanges, OnDestroy, OnInit {
uploading = false; uploading = false;
checking = false; checking = false;
checkVideoExists = false; checkVideoExists = false;
...@@ -24,8 +60,7 @@ export class UploadVideoComponent implements OnChanges, OnDestroy { ...@@ -24,8 +60,7 @@ export class UploadVideoComponent implements OnChanges, OnDestroy {
@Input() @Input()
videoUrl = ''; videoUrl = '';
@ViewChild('videoNode', {static: true }) @ViewChild('videoNode', {static: false }) videoNode: ElementRef;
videoNode: ElementRef;
...@@ -38,11 +73,17 @@ export class UploadVideoComponent implements OnChanges, OnDestroy { ...@@ -38,11 +73,17 @@ export class UploadVideoComponent implements OnChanges, OnDestroy {
@Output() @Output()
videoUploadFailure = new EventEmitter(); videoUploadFailure = new EventEmitter();
@Output() @Output()
onCapture = new EventEmitter();
@Output()
extractVideoCover = new EventEmitter(); extractVideoCover = new EventEmitter();
@Output()
parentRef= new EventEmitter();
@Input() @Input()
showUploadBtn = true; showUploadBtn = true;
@Input()
showCapture = true;
@Input() @Input()
item: any; item: any;
// videoItem = null; // videoItem = null;
...@@ -51,7 +92,8 @@ export class UploadVideoComponent implements OnChanges, OnDestroy { ...@@ -51,7 +92,8 @@ export class UploadVideoComponent implements OnChanges, OnDestroy {
uploadData; uploadData;
constructor(private nzMessageService: NzMessageService, constructor(private nzMessageService: NzMessageService,
private sanitization: DomSanitizer private sanitization: DomSanitizer,
private http:HttpClient
// private cwService: _coursewareService, // private cwService: _coursewareService,
// private resService: ResourceService // private resService: ResourceService
) { ) {
...@@ -59,16 +101,28 @@ export class UploadVideoComponent implements OnChanges, OnDestroy { ...@@ -59,16 +101,28 @@ export class UploadVideoComponent implements OnChanges, OnDestroy {
this.videoFile = null; this.videoFile = null;
this.uploadUrl = (<any> window).courseware.uploadUrl(); this.uploadUrl = (<any> window).courseware.uploadVideoUrl();
this.uploadData = (<any> window).courseware.uploadData(); this.uploadData = (<any> window).courseware.uploadData();
window['air'].getUploadCallback = (url, data) => {
window['air'].getUploadVideoCallback = (url, data) => {
this.uploadUrl = url; this.uploadUrl = url;
this.uploadData = data; this.uploadData = data;
}; };
} }
ngOnInit() {
this.playVedioUrl = this.videoUrl.replace(`_h.mp4`, `_l.mp4`);;
this.parentRef.emit(this);
}
md5Short(str, _salt) {
return cryptoJs.MD5(str + _salt).toString();
}
ngOnChanges() { ngOnChanges() {
// if (!this.videoFile || this.showUploadBtn) { // if (!this.videoFile || this.showUploadBtn) {
// return; // return;
...@@ -80,57 +134,132 @@ export class UploadVideoComponent implements OnChanges, OnDestroy { ...@@ -80,57 +134,132 @@ export class UploadVideoComponent implements OnChanges, OnDestroy {
URL.revokeObjectURL(this.videoUrl); URL.revokeObjectURL(this.videoUrl);
} }
safeVideoUrl(url) { playVedioUrl = '';
console.log(url); safeVideoUrl() {
return this.sanitization.bypassSecurityTrustResourceUrl(url); // `${url}`; const url = this.videoUrl.replace(`_h.mp4`, `.mp4`).replace(`.mp4`, `_l.mp4`);
console.log(`=============safeVideoUrl==============`,url);
httpHeadCall(url, (flag)=>{
if (flag) {
this.playVedioUrl = url;
} else {
setTimeout(() => {
this.safeVideoUrl();
}, 500);
}
});
return this.playVedioUrl;
} }
videoLoadedMetaData() { videoLoadedMetaData() {
} }
// fileToBlob(file) {
// let reader = new FileReader();
// reader.addEventListener('load', (e) => {
// let base64 = e.target.result;
// let blob = new Blob([base64], { type: file.type });
// console.log("Blob对象", blob);
// })
// reader.readAsDataURL(file)
// }
handleChange(info: UploadChangeParam/* { type: string, file: UploadFile, event: any }*/): void {
console.log('info:' , info);
customReq(item: UploadXHRArgs) {
// md5值计算
const protocolStr = document.location.protocol;
const baseCDN = `${protocolStr}//teach.cdn.ireadabc.com`;
const baseAPI = `${protocolStr}//openapi.iteachabc.com`;
const fileName = v4().replace(/-/g, "");
httpHeadCall(`${baseCDN}/${fileName}_h.mp4`, (flag)=>{
if (flag) {
item.onSuccess!({url: `${baseCDN}/${fileName}.mp4`}, item.file!, event);
} else {
this.http.get(`${baseAPI}/oss/timestamp`, {}).subscribe((tmps: string) => {
const sign = cryptoJs.MD5(tmps+"iplayABC2019").toString();
const params = new HttpParams().set('sign', sign).set('stmp', tmps);
this.http.get(`${baseAPI}/oss/signature`, {params}).subscribe((res) => {
const serve = res['data'];
const formData = new FormData();
formData.append('key', fileName+`.mp4`);
formData.append('bucket', `iplayabc-worker-queue-videolmh`);
formData.append('OSSAccessKeyId', serve.OSSAccessKeyId);
formData.append('policy', serve.policy);
formData.append('Signature', serve.signature);
formData.append('success_action_status', '200');
formData.append('file', item.file as any);
//设置cors跨域请求
const headerstemp = new HttpHeaders({ 'Access-Control-Allow-Origin': '*' });
item.action = `${protocolStr}//iplayabc-worker-queue-videolmh.oss-cn-beijing.aliyuncs.com`;
const req = new HttpRequest('POST', item.action!, formData, {
headers: headerstemp,
reportProgress: true, //进度条
// withCredentials: true
});
// Always returns a `Subscription` object. nz-upload would automatically unsubscribe it at correct time.
return this.http.request(req).subscribe(
(event: HttpEvent<{}>) => {
if (event.type === HttpEventType.UploadProgress) {
if (event.total! > 0) {
// tslint:disable-next-line:no-any
(event as any).percent = (event.loaded / event.total!) * 100;
}
item.onProgress!(event, item.file!);
} else if (event instanceof HttpResponse) {
item.onSuccess!({url: `${baseCDN}/${fileName}.mp4`}, item.file!, event);
}
},
err => {
console.log(err);
item.onError!(err, item.file!);
}
);
});
});
}
});
};
handleChange(info: UploadChangeParam/* { type: string, file: UploadFile, event: any }*/): void {
console.log(info);
switch (info.type) { switch (info.type) {
case 'start': case 'start':
// this.beforeUpload(info.file);
if (!this.checkSelectFile(info.file)) { if (!this.checkSelectFile(info.file)) {
return; return;
} }
this.uploading = true; this.uploading = true;
this.progress = 0; this.progress = 0;
break; break;
case 'success': case 'success':
this.uploadSuccess(info.file); this.uploadSuccess(info.file);
// this.beforeUpload(info.file);
// this.uploadSuccess(info.file);
break; break;
case 'progress': case 'progress':
this.progress = info.event.percent; this.progress = info.event.percent;
this.doProgress(this.progress); // this.doProgress(this.progress);
break; break;
} }
} }
captureVideo() {
const video = this.videoNode.nativeElement;
const cvs = document.createElement('canvas');
const ctx = cvs.getContext("2d");
cvs.width = video.videoWidth;
cvs.height = video.videoHeight;
ctx.drawImage(video, 0, 0, cvs.width, cvs.height);
cvs.toBlob((blob)=>{
this.onCapture.emit(blob)
})
}
checkSelectFile(file) { checkSelectFile(file) {
if (!file.lastModified) { if (!file.lastModified) {
return false; return false;
} }
const delta = 500; const delta = 5000;
const isOverSize = (file.size / 1024 / 1024) < delta; const isOverSize = (file.size / 1024 / 1024) < delta;
if (!isOverSize) { if (!isOverSize) {
this.nzMessageService.error(`video must smaller than ${delta}MB!`); this.nzMessageService.error(`video must smaller than ${delta}MB!`);
...@@ -144,45 +273,16 @@ export class UploadVideoComponent implements OnChanges, OnDestroy { ...@@ -144,45 +273,16 @@ export class UploadVideoComponent implements OnChanges, OnDestroy {
} }
uploadSuccess = (file) => { uploadSuccess = (file) => {
this.nzMessageService.info('Upload Success'); this.nzMessageService.info('Upload Success');
// this.updateStatus(false); // this.updateStatus(false);
this.uploading = false; this.uploading = false;
this.videoFile = null; this.videoFile = null;
// this.updateSource(url); // this.updateSource(url);
this.videoUrl = file.response.url; this.videoUrl = file.response.url;
// console.log(this.picUrl)
// this.uploadFinished(url);
// if (!inOSS) {
const vid = document.createElement('video');
vid.addEventListener('loadedmetadata', () => {
const height = vid.videoHeight;
const width = vid.videoWidth;
let duration = vid.duration;
if (duration) {
duration = duration * 1000;
}
file.height = height;
file.width = width;
file.duration = duration;
vid.preload = 'none';
vid.src = '';
vid.remove();
this.videoUploaded.emit(file.response); this.videoUploaded.emit(file.response);
setTimeout(() => {
// this.resService.updateVideo(id, {width, height, duration}).then( () => { this.safeVideoUrl();
// this.videoUploaded.emit({res_id: id, id, name, hash, url}); }, 2000);
// });
});
vid.src = file.response.url;
// } else {
// this.videoUploaded.emit(file.response);
// }
} }
......
import { ApplicationRef, ChangeDetectorRef, ElementRef, ViewChild } from "@angular/core";
export class ComponentBase {
// 储存数据用
saveKey = "";
// 储存对象
item: any = {};
ngOnChanges() { }
ngOnDestroy() { }
constructor(public appRef: ApplicationRef, public changeDetectorRef: ChangeDetectorRef) { }
ngOnInit() {
// 获取存储的数据
(<any>window).courseware.getData((data) => {
if (data) {
this.item = data;
this.itemStr = JSON.stringify(this.item, null, 4).trim();
}
this.init();
this.changeDetectorRef.markForCheck();
this.changeDetectorRef.detectChanges();
this.refresh();
}, this.saveKey);
}
@ViewChild("itemTextarea", { static: true }) itemTextarea: ElementRef;
copyData() {
this.itemTextarea.nativeElement.select();
document.execCommand("copy");
}
/**
* 储存图片数据
* @param e
*/
// onAssetUploadSuccess(e: any, ...key: Array<string>) {
// let item = this.item;
// for (let i = 0; i < key.length; i++) {
// if (i + 1 == key.length) {
// if (key[i] == "audio") {
// let sp = e.url.split(".mp3");
// let u = sp[0] + "_l.mp3";
// item[key[i]] = u;
// item["audioName"] = e.name || "";
// } else {
// item[key[i]] = e.url;
// }
// this.save();
// return;
// }
// item = item[key[i]];
// }
// }
/**
* 储存图片数据
* @param e
*/
onImageUploadSuccess(e, key, it = this.item) {
it[key] = e.url;
this.save();
}
/**
* 储存音频数据
* @param e
*/
onAudioUploadSuccess(e, key, it = this.item) {
let url = e.url;
let sp = url.split(".mp3");
let u = sp[0] + "_l.mp3";
it[key] = u;
it["audioName"] = e.name || "";
this.save();
}
save() {
(<any>window).courseware.setData(this.item, null, this.saveKey);
this.itemStr = JSON.stringify(this.item, null, 4).trim();
this.refresh();
console.log('this.item = ' + JSON.stringify(this.item));
}
itemStr = "";
load() {
this.itemStr = this.itemTextarea.nativeElement.value;
if (this.isJSON(this.itemStr)) {
this.item = JSON.parse(this.itemStr);
this.init();
this.changeDetectorRef.markForCheck();
this.changeDetectorRef.detectChanges();
this.refresh();
}
}
isJSON(str) {
if (typeof str == 'string') {
try {
var obj = JSON.parse(str);
if (typeof obj == 'object' && obj) {
return true;
}
return false;
} catch (e) {
return false;
}
}
return false;
}
/**
* 刷新 渲染页面
*/
refresh() {
setTimeout(() => {
this.appRef.tick();
}, 1);
}
init() { }
}
...@@ -4,5 +4,74 @@ ...@@ -4,5 +4,74 @@
.model-content { .model-content {
width: 100%; width: 100%;
height: 100%; height: 100%;
}
.radioPaire {
float: left;
margin: 3px;
border-style: dashed;
border-color: #000;
border-width: 1px;
}
.border {
border-radius: 20px;
border-style: dashed;
padding: 20px;
margin: 20px;
/*width: 500px; */
/*//border-radius: 20px;*/
/*//border-width: 2px;*/
/*//border-color: #000000;*/
}
.border-lite {
border: 2px dashed #ddd;
border-radius: 0.5rem;
padding: 10px;
margin: 10px;
}
.button-right {
border-style: dotted;
border-color: rgb(42, 142, 72);
width: 100px;
height: 50px;
color: rgb(42, 142, 72);
background-color: white;
}
.button-wrong {
border-style: dotted;
border-color: rgb(180, 0, 0);
width: 100px;
height: 50px;
color: rgb(180, 0, 0);
background-color: white;
}
.button-disable {
border-style: dotted;
border-color: black;
width: 100px;
height: 50px;
color: black;
background-color: white;
}
#drop-area {
border: 2px dashed #ccc;
padding: 20px;
text-align: center;
font-family: Arial, sans-serif;
font-size: 16px;
cursor: pointer;
}
#drop-area:hover {
background-color: #f0f0f0;
}
#drop-area:active {
background-color: #e0e0e0;
} }
<div style="margin: 30px;"> <div style="margin: 30px;">
<div style="margin: 30px; display: flex; align-items: center; "> <div class="model-content">
<h3>单元名称:</h3>
<textarea nz-input [(ngModel)]="item.title" (blur)="save()" style=" width: 350px; min-height: 60px;"></textarea>
</div>
<div style="margin: 30px; display: flex; align-items: center; "> <div style="width: 100%; display: inline-flex;flex-direction: row;flex-wrap: wrap;">
<h3>视频互动课名称:</h3> <div style="margin:30px; border: 2px #ccc solid; border-radius: 15px; width: 1000px; padding: 20px 10px 5px 10px; display: flex; justify-content: center;" >
<textarea nz-input [(ngModel)]="item.video_title" (blur)="save()" style=" width: 350px; min-height: 60px;"></textarea>
</div>
<div style="margin: 30px; display: flex; align-items: center; "> <div >
<h3>上传视频封面:</h3> <div style="width: 100px; background-color: white; transform: translateY(-30px);" > </div>
<div style="margin-top: 10px; display: flex; ">
<div style="width: 300px;"> <div style="margin: 20px; display: flex; align-items: center; ">
<app-upload-image-with-preview [picUrl]="item.video_cover_pic" <h3 style="width: 150px; text-align:left">单元名称:</h3>
(imageUploaded)="onItemImgUploadSuccess($event, item, 'video_cover_pic')"> <textarea nz-input [(ngModel)]="item.title" (blur)="save()" style=" width: 640px; min-height: 60px;"></textarea>
</app-upload-image-with-preview>
</div> </div>
<button *ngIf="item.video_cover_pic" style="float: right;" nz-button nzType="" (click)="onDeletaImg(item, 'video_cover_pic')">
x <div style="margin: 20px; display: flex; align-items: center; ">
</button> <h3 style="min-width: 150px; text-align:left">视频互动课名称:</h3>
<textarea nz-input [(ngModel)]="item.video_title" (blur)="save()" style=" width: 640px; min-height: 60px;"></textarea>
</div> </div>
<br>
<div style="margin: 20px; display: flex; align-items: center; ">
<h3 style="min-width: 150px; text-align:left">上传视频封面:</h3>
<app-upload-image-with-preview style="width: 80%; max-width: 640px;" [picUrl]="item.cover" (imageUploaded)="onImageUploadSuccess($event)">
</app-upload-image-with-preview>
</div> </div>
<div style="margin: 30px; display: flex; align-items: center; "> <div style="margin: 20px; display: flex; align-items: center; ">
<h3 style="">上传视频:</h3> <h3 style="min-width: 150px; text-align:left">上传视频:</h3>
<div style="display: flex; margin: 30px; width: 1000px"> <app-upload-video style="width: 80%; max-width: 640px;" [videoUrl]="item.video_url.replace('.mp4', '_h.mp4')" [showUploadBtn]="true" (videoUploaded)="onVideoUploaded($event)"
(onCapture)="handleOnCapture($event)" (parentRef)="onParentRef($event)"></app-upload-video>
<div>
<div style="width: 300px; border: 1px #ccc solid; border-radius: 15px; padding: 15px; background-color: rgb(255, 250, 243);">
<app-upload-video
[videoUrl]="item.video_url"
(videoUploaded)="onVideoUploaded($event)"
>
</app-upload-video>
</div> </div>
<!-- <div style="padding: 5px 0;" align='center'>
<h3>视频地址</h3>
<input type="text" nz-input [(ngModel)]="item.video_url" disabled (blur)="save()">
</div> -->
</div> </div>
</div> </div>
</div> </div>
</div>
...@@ -79,9 +74,12 @@ ...@@ -79,9 +74,12 @@
</nz-input-number> </nz-input-number>
<span></span> <span></span>
</div> </div>
<span *ngIf="ques.time" style="color: #999; font-size: 12px; margin-left: 5px;">
(总计: {{ques.time | time}}) </div>
</span> <div style="display: flex; align-items: center; gap: 5px;">
<button style="float: right; margin-right: -140px;" nz-button (click)="setFragment(ques,i)">
去设置
</button>
</div> </div>
</div> </div>
<button style="float: right; margin-right: -140px;" nz-button nzType="danger" (click)="deletePageBtnClick(i)"> <button style="float: right; margin-right: -140px;" nz-button nzType="danger" (click)="deletePageBtnClick(i)">
...@@ -166,6 +164,27 @@ ...@@ -166,6 +164,27 @@
<!-- 判断题 --> <!-- 判断题 -->
<div *ngIf="question.type=='judgment'" style="border: 1px #ccc solid; border-radius: 15px; padding: 15px; background-color: rgb(255, 250, 243);"> <div *ngIf="question.type=='judgment'" style="border: 1px #ccc solid; border-radius: 15px; padding: 15px; background-color: rgb(255, 250, 243);">
<div style="margin-bottom: 10px; display: flex; align-items: center;">
<div style="width: 300px;">
<app-upload-image-with-preview [picUrl]="question.pic_url"
(imageUploaded)="onItemImgUploadSuccess($event, question, 'pic_url')">
</app-upload-image-with-preview>
</div>
<button *ngIf="question.pic_url" style="margin-left: 10px;" nz-button nzType="" (click)="onDeletaImg(question, 'pic_url')">
x
</button>
</div>
<div style="margin-bottom: 10px; display: flex; align-items: center;">
<app-audio-recorder [audioUrl]="question.audio_url"
(audioUploaded)="onItemAudioUploadSuccess($event, question, 'audio_url')">
</app-audio-recorder>
<button *ngIf="question.audio_url" style="margin-left: 10px;" nz-button nzType="danger" (click)="onDeletaImg(question, 'audio_url')">
x
</button>
</div>
<div style="margin-top: 5px; display: flex; align-items: center; "> <div style="margin-top: 5px; display: flex; align-items: center; ">
<span style="">问题:</span> <span style="">问题:</span>
<textarea nz-input [(ngModel)]="question.text" (blur)="save()" style=" width: 300px; min-height: 60px;"></textarea> <textarea nz-input [(ngModel)]="question.text" (blur)="save()" style=" width: 300px; min-height: 60px;"></textarea>
...@@ -192,98 +211,101 @@ ...@@ -192,98 +211,101 @@
</div> </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 style="padding: 30px;">
<button nz-button nzType="dashed" (click)="addOnePageBtnClick()" style="width: 200px; height: 100px;">
<i nz-icon nzType="plus-circle" nzTheme="outline"></i>
添加视频节点
</button>
</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>
<!-- 视频片段选择弹窗 -->
<nz-modal
[(nzVisible)]="isFragmentModalVisible"
nzTitle="选择视频时间节点"
(nzOnCancel)="fragmentModalCancel()"
[nzFooter]="null"
[nzWidth]="900">
<!-- 视频播放器 -->
<div style="padding: 20px;">
<div style="margin-bottom: 20px; text-align: center;">
<video
id="fragment-video"
[src]="item.video_url.replace('.mp4', '_h.mp4')"
crossorigin="anonymous"
controls
style="width: 100%; max-width: 800px;"
(loadedmetadata)="onFragmentVideoLoadedMetadata($event)"
(timeupdate)="onFragmentVideoTimeUpdate($event)"
(error)="onFragmentVideoError($event)">
您的浏览器不支持视频播放
</video>
</div>
<div *ngIf="ques.optionTypeText" style="margin-top: 5px; display: flex; align-items: center; "> <div style="margin-bottom: 20px; text-align: end;" id="video-preview-btn">
<span style="">文本:</span> <button nz-button nzType="primary" (click)="onVideoPreview()">效果试看</button>
<input type="text" nz-input [(ngModel)]="option.text" (blur)="save()" style=" width: 200px;">
</div> </div>
<div *ngIf="ques.optionTypeAudio" style="margin-top: 10px; display: flex; align-items: center;"> <!-- 视频帧轨道 -->
<span style="">音频:</span> <div style="margin-bottom: 20px;">
<app-audio-recorder [audioUrl]="option.audio_url" <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
(audioUploaded)="onItemAudioUploadSuccess($event, option, 'audio_url')"> <span>当前时间: {{formatTime(fragmentSelectedTime)}}</span>
</app-audio-recorder> <span>总时长: {{formatTime(fragmentVideoDuration)}}</span>
</div> </div>
<div *ngIf="ques.optionTypePic" style="margin-top: 10px; display: flex; "> <!-- 视频帧缩略图轨道 -->
<span style="">图片:</span> <div *ngIf="videoThumbnails.length > 0" style="margin-bottom: 45px;">
<div style="width: 300px;"> <div #thumbnailContainer style="display: flex; overflow-x: auto; padding: 10px 0; border: 1px solid #e8e8e8; border-radius: 4px; background-color: #fafafa;">
<app-upload-image-with-preview [picUrl]="option.pic_url" <div
(imageUploaded)="onItemImgUploadSuccess($event, option, 'pic_url')"> *ngFor="let thumbnail of videoThumbnails; let i = index"
</app-upload-image-with-preview> [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;">
<img
[src]="thumbnail"
[alt]="'帧 ' + (i + 1)"
style="width: 80px; height: 45px; object-fit: cover; display: block; border-radius: 2px;">
<div style="font-size: 10px; text-align: center; color: #666; margin-top: 2px;">
{{formatTime(getThumbnailTime(i))}}
</div>
</div> </div>
<button *ngIf="option.pic_url" style="float: right;" nz-button nzType="" (click)="onDeletaImg(option, 'pic_url')">
x
</button>
</div> </div>
</div> </div>
</div> --> <!-- 生成缩略图提示 -->
<div *ngIf="isGeneratingThumbnails" style="text-align: center; padding: 10px; color: #999;">
<i nz-icon nzType="loading" nzTheme="outline"></i>
<!-- <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>
<div style="padding: 30px;"> <!-- 时间滑块 -->
<button nz-button nzType="dashed" (click)="addOnePageBtnClick()" style="width: 200px; height: 100px;"> <nz-slider
<i nz-icon nzType="plus-circle" nzTheme="outline"></i> [nzMin]="0"
添加视频节点 [nzMax]="fragmentVideoDuration"
[nzStep]="0.1"
[ngModel]="fragmentSelectedTime"
(ngModelChange)="onFragmentTrackChange($event)"
(nzOnBeforeChange)="onFragmentTrackStart()"
(nzOnAfterChange)="onFragmentTrackEnd()"
[nzTooltipVisible]="isFragmentModalVisible ? 'always' : 'never'">
</nz-slider>
</div>
<!-- 确认和取消按钮 -->
<div style="text-align: right; margin-top: 30px;">
<button nz-button nzType="default" (click)="fragmentModalCancel()" style="margin-right: 10px;">
取消
</button>
<button nz-button nzType="primary" (click)="fragmentModalOk()">
确认节点
</button> </button>
</div> </div>
</div> </div>
</nz-modal>
......
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 { JsonPipe } from '@angular/common';
import { NzMessageService } from 'ng-zorro-antd/message'; import { NzMessageService } from 'ng-zorro-antd/message';
import { UploadChangeParam, UploadXHRArgs } from 'ng-zorro-antd/upload';
import { ComponentBase } from './ComponentBase';
@Component({ @Component({
...@@ -8,15 +10,22 @@ import { NzMessageService } from 'ng-zorro-antd/message'; ...@@ -8,15 +10,22 @@ import { NzMessageService } from 'ng-zorro-antd/message';
templateUrl: './form.component.html', templateUrl: './form.component.html',
styleUrls: ['./form.component.css'] styleUrls: ['./form.component.css']
}) })
export class FormComponent implements OnInit, OnChanges, OnDestroy { export class FormComponent extends ComponentBase implements OnInit, OnChanges, OnDestroy {
// 储存数据用 // 储存数据用
saveKey = "JJ_Vedio_Practice"; saveKey = "JJ_Vedio_Practice";
thumbnailCacheKey = "JJ_Video_Thumbnail_Cache"; // localStorage 缓存键名
baseData; baseData;
// 储存对象 // 储存对象
item; item: any = {
video_url: "",
cover: "",
title: "",
video_title: "",
quesArr: []
};
//临时储存对象 //临时储存对象
tempItem; tempItem;
...@@ -31,384 +40,26 @@ export class FormComponent implements OnInit, OnChanges, OnDestroy { ...@@ -31,384 +40,26 @@ export class FormComponent implements OnInit, OnChanges, OnDestroy {
isPreviewTemplate = false; isPreviewTemplate = false;
// 视频片段选择弹窗相关
isFragmentModalVisible = false;
currentFragmentQues = null;
currentFragmentIndex = -1;
fragmentVideoUrl = '';
fragmentVideoCurrentTime = 0;
fragmentVideoDuration = 0;
fragmentSelectedTime = 0;
fragmentVideoElement: HTMLVideoElement = null;
isDraggingTrack = false;
videoThumbnails: string[] = []; // 视频帧缩略图数组
isGeneratingThumbnails = false; // 是否正在生成缩略图
thumbnailCount = 20; // 生成的缩略图数量
thumbnailCache: Map<string, { thumbnails: string[], duration: number, count: number }> = new Map(); // 缩略图缓存
@ViewChild('thumbnailContainer', { static: false }) thumbnailContainer: ElementRef; // 缩略图容器引用
isPreviewing = false; // 是否正在预览播放
previewTargetTime = 0; // 预览目标时间
customTypeGroupArr = [
{
name: '输入型文本',
rect: true,
isShowPos: true,
labelArr: [
{type:"inputText", key: "title", title:"标题"},
{key: "key", title:"key"},
{key: "fontName", title:"字体"},
{key: "fontSize", title:"大小"},
{key: "fontColor", title:"颜色"},
],
checkbox: {
title: '',
list: [
{label: '自动换行', value:'warp'}
]
}
},
{
name: '固定型文本',
text: true,
audio: true,
isShowPos: true,
labelArr: [
{key: "fontName", title:"字体"},
{key: "fontSize", title:"大小"},
{key: "fontColor", title:"颜色"},
]
},
{
name: '输入型图片',
rect: true,
isShowPos: true,
labelArr: [
{type:"inputPic", key: "title", title:"标题"},
{key: "key", title:"key", value:""},
]
},
{
name: '固定型图片',
pic: true,
audio: true,
anchorGroup: true,
isShowPos: true,
},
{
name: '输入型动画',
rect: true,
isShowPos: true,
labelArr: [
{key: "title", title:"标题"},
]
},
{
name: '固定型动画',
rect: true,
anima: true,
audio: true,
isShowPos: true,
},
{
name: '输入型音频',
rect: true,
isShowPos: true,
labelArr: [
{type:"inputAudio", key: "title", title:"标题"},
{key: "key", title:"key", value:""},
]
},
{
name: '固定型音频',
rect: true,
audio: true,
pic: true,
anim: true,
isShowPos: true,
},
{
name: '数据型文本',
data: true,
labelArr: [
{type:"inputText", key: "title", title:"标题"},
{key: "key", title:"key"},
]
},
{
name: '数据型图片',
// data: true,
labelArr: [
{type:"inputPic", key: "title", title:"标题", value:""},
{key: "key", title:"key", value:""},
]
},
{
name: '翻页按钮组',
rect: true,
audio: true,
// radio: {
// title: "按钮",
// list: [
// {label: '上一页', key: 'prev'},
// {label: '下一页', key: 'next'},
// {label: '翻页文本', key: 'text'},
// ]
// },
isShowPos: true,
},
{
name: '录音按钮组',
rect: true,
audio: true,
isShowPos: true,
},
{
name: '文字选项组',
rect: true,
picArr: [
{title: '默认图'},
{title: '正确图'},
{title: '错误图'},
],
labelArr: [
{type:"inputText", key: "title", title:"标题"},
{key: "key", title:"key"},
{key: "fontSize", title:"字体大小"},
{key: "fontName1", title:"普通字体"},
{key: "fontColor1", title:"普通颜色"},
{key: "fontName2", title:"正确字体"},
{key: "fontColor2", title:"正确颜色"},
{key: "fontName3", title:"错误字体"},
{key: "fontColor3", title:"错误颜色"},
],
checkbox: {
title: '',
list: [
{label: '适配矩形', key:'isScale'},
]
},
isOption: true,
isShowPos: true,
isCopy: true,
},
{
name: '图片选项组',
rect: true,
picArr: [
{title: '输入参考图'},
{title: '默认背景图'},
{title: '正确背景图'},
{title: '错误背景图'},
],
labelArr: [
{type:"inputPic", key: "title", title:"标题"},
{key: "key", title:"key"},
],
checkbox: {
title: '',
list: [
{label: '遮罩', key:'isMask'},
{label: '适配矩形', key:'isScale'},
]
},
isOption: true,
isShowPos: true,
isCopy: true,
},
{
name: '音频型动画',
rect: true,
anima: true,
labelArr: [
{type:"inputAudio", key: "title", title:"标题"},
{key: "key", title:"key"},
],
isShowPos: true,
isCopy: true,
},
{
name: '选项效果组',
picArr: [
{title: '正确效果'},
{title: '错误效果'},
],
audioArr: [
{title: '正确音效'},
{title: '错误音效'},
],
anchorGroup: true,
isCopy: true,
},
{
name: '数组排列区',
rect: true,
checkbox: {
title: '',
list: [
{label: '横向排列', key:'hor'},
]
},
// select: {
// title: '类型',
// list:[
// {label: '拖拽开始区', value:'dragStart'},
// {label: '拖拽结束区', value:'dragEnd'},
// ]
// },
labelArr: [
{key: "id", title:"id"},
{key: "distanceX", title:"横向间距"},
{key: "distanceY", title:"纵向间距"},
],
isShowPos: true,
isShowSize: true,
isCopy: true,
},
{
name: '文字排列物',
rect: true,
picArr: [
{title: '默认图'},
{title: '正确图'},
{title: '错误图'},
],
labelArr: [
{type:"listInputText", key: "title", title:"标题"},
{key: "key", title:"key"},
{key: "id", title:"id"},
{key: "fontSize", title:"字体大小"},
{key: "fontName1", title:"普通字体"},
{key: "fontColor1", title:"普通颜色"},
{key: "fontName2", title:"正确字体"},
{key: "fontColor2", title:"正确颜色"},
{key: "fontName3", title:"错误字体"},
{key: "fontColor3", title:"错误颜色"},
],
radio: {
title: "文字位置",
list: [
{label: '开头', key: 'start'},
{label: '中间', key: 'middle'},
{label: '结尾', key: 'end'},
]
},
// checkbox: {
// title: '',
// list: [
// {label: '结束块', key:'isEndRect'},
// ]
// },
isShowPos: true,
isCopy: true,
},
{
name: '图片排列物',
rect: true,
picArr: [
{title: '输入参考图'},
{title: '默认背景图'},
{title: '正确背景图'},
{title: '错误背景图'},
],
labelArr: [
{type:"listInputPic", key: "title", title:"标题"},
{key: "key", title:"key"},
{key: "id", title:"id"},
],
checkbox: {
title: '',
list: [
{label: '遮罩', key:'isMask'},
// {label: '结束块', key:'isEndRect'},
]
},
isShowPos: true,
isCopy: true,
},
{
name: '拖拽文字组',
rect: true,
picArr: [
{title: '默认图'},
{title: '正确图'},
{title: '错误图'},
],
labelArr: [
{type:"listInputText", key: "title", title:"标题"},
{key: "key", title:"key"},
{key: "fontSize", title:"字体大小"},
{key: "fontName1", title:"普通字体"},
{key: "fontColor1", title:"普通颜色"},
{key: "fontName2", title:"正确字体"},
{key: "fontColor2", title:"正确颜色"},
{key: "fontName3", title:"错误字体"},
{key: "fontColor3", title:"错误颜色"},
],
checkbox: {
title: '',
list: [
{label: '结束块', key:'isEndRect'},
]
},
isShowPos: true,
isCopy: true,
},
{
name: '拖拽图片组',
rect: true,
picArr: [
{title: '输入参考图'},
{title: '默认背景图'},
{title: '正确背景图'},
{title: '错误背景图'},
],
labelArr: [
{type:"listInputPic", key: "title", title:"标题"},
{key: "key", title:"key"},
],
checkbox: {
title: '',
list: [
{label: '遮罩', key:'isMask'},
{label: '结束块', key:'isEndRect'},
]
},
isShowPos: true,
isCopy: true,
},
];
...@@ -488,15 +139,17 @@ export class FormComponent implements OnInit, OnChanges, OnDestroy { ...@@ -488,15 +139,17 @@ export class FormComponent implements OnInit, OnChanges, OnDestroy {
] ]
constructor(private appRef: ApplicationRef, private changeDetectorRef: ChangeDetectorRef, constructor(public appRef: ApplicationRef, public changeDetectorRef: ChangeDetectorRef,
private message: NzMessageService) { private message: NzMessageService) {
super(appRef, changeDetectorRef);
} }
ngOnInit() { ngOnInit() {
this.item = {};
// 从 localStorage 加载缩略图缓存
this.loadThumbnailCacheFromLocalStorage();
// 获取存储的数据 // 获取存储的数据
(<any>window).courseware.getData((data) => { (<any>window).courseware.getData((data) => {
...@@ -511,7 +164,6 @@ export class FormComponent implements OnInit, OnChanges, OnDestroy { ...@@ -511,7 +164,6 @@ export class FormComponent implements OnInit, OnChanges, OnDestroy {
console.log("this.item: ", JSON.stringify(this.item)); console.log("this.item: ", JSON.stringify(this.item));
this.init();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
this.changeDetectorRef.detectChanges(); this.changeDetectorRef.detectChanges();
this.refresh(); this.refresh();
...@@ -519,46 +171,588 @@ export class FormComponent implements OnInit, OnChanges, OnDestroy { ...@@ -519,46 +171,588 @@ export class FormComponent implements OnInit, OnChanges, OnDestroy {
}, this.saveKey); }, this.saveKey);
} }
initItem() { setFragment(ques, i) {
console.log('i: ', i);
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) {
// 确保设置 crossOrigin 属性(必须在设置 src 之前或重新加载时设置)
if (!this.fragmentVideoElement.crossOrigin) {
this.fragmentVideoElement.crossOrigin = 'anonymous';
} }
init() { // 如果视频已经加载完成,获取时长并生成缩略图
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 中生成
}
}
}
}, 100);
}
if (!this.item.quesArr) { // 视频片段弹窗 - 取消
this.item.quesArr = []; fragmentModalCancel() {
// 如果正在预览,停止播放
if (this.isPreviewing && this.fragmentVideoElement) {
this.fragmentVideoElement.pause();
this.isPreviewing = false;
} }
if (!this.item.partArr) { this.isFragmentModalVisible = false;
this.item.partArr = []; this.currentFragmentQues = null;
this.currentFragmentIndex = -1;
this.fragmentVideoCurrentTime = 0;
this.fragmentVideoDuration = 0;
this.fragmentSelectedTime = 0;
this.fragmentVideoElement = null;
this.isDraggingTrack = false;
this.videoThumbnails = []; // 只清除当前显示的缩略图,保留缓存
this.isGeneratingThumbnails = false;
this.previewTargetTime = 0;
} }
// 兼容旧数据结构:如果视频节点没有questionArr,则转换为新结构 // 清除指定视频的缩略图缓存
if (this.item.quesArr) { clearThumbnailCache(videoUrl?: string) {
this.item.quesArr.forEach(ques => { if (videoUrl) {
if (!ques.questionArr) { this.thumbnailCache.delete(videoUrl);
// 旧数据结构:直接有type和optionArr // 同时从 localStorage 清除
if (ques.type) { this.removeThumbnailCacheFromLocalStorage(videoUrl);
ques.questionArr = [{ console.log('已清除缓存:', videoUrl);
type: ques.type,
text: ques.text || "",
optionArr: ques.optionArr || []
}];
// 保留原有属性,但主要使用questionArr
} else { } else {
// 新节点但没有questionArr,初始化 // 清除所有缓存
ques.questionArr = [{ this.thumbnailCache.clear();
type: "select", // 同时清除 localStorage
text: "", this.clearThumbnailCacheFromLocalStorage();
optionArr: [] 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 = 100 * 1024 * 1024; // 100MB 限制,留一些余量
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();
}
// 视频加载元数据
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。');
}
}
}
// 视频时间更新
onFragmentVideoTimeUpdate(event) {
const video = event.target;
// 如果正在预览,检查是否到达目标时间
if (this.isPreviewing && video.currentTime >= this.previewTargetTime) {
video.pause();
video.currentTime = this.previewTargetTime;
this.isPreviewing = false;
this.message.create('success', '预览完成,已到达指定时间节点');
return;
}
if (!this.isDraggingTrack) {
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();
}
}
}
// 效果试看:从0开始播放到指定时间节点
onVideoPreview() {
if (!this.fragmentVideoElement) {
this.message.create('warning', '视频未加载,请稍候再试');
return;
}
if (!this.fragmentSelectedTime || this.fragmentSelectedTime <= 0) {
this.message.create('warning', '请先选择时间节点');
return;
}
const video = this.fragmentVideoElement;
// 设置预览目标时间
this.previewTargetTime = this.fragmentSelectedTime;
this.isPreviewing = true;
// 跳转到0秒并开始播放
video.currentTime = 0;
video.play().then(() => {
this.message.create('info', '开始预览,将播放到 ' + this.formatTime(this.previewTargetTime));
}).catch((error) => {
console.error('播放失败:', error);
this.message.create('error', '播放失败,请检查视频');
this.isPreviewing = false;
});
}
// 轨道拖动开始
onFragmentTrackStart() {
this.isDraggingTrack = true;
}
// 轨道拖动中
onFragmentTrackChange(value: number) {
this.fragmentSelectedTime = value;
if (this.fragmentVideoElement) {
this.fragmentVideoElement.currentTime = value;
}
// 滚动到对应的缩略图
this.scrollToActiveThumbnail();
}
// 轨道拖动结束
onFragmentTrackEnd() {
this.isDraggingTrack = false;
if (this.fragmentVideoElement) {
this.fragmentVideoElement.currentTime = this.fragmentSelectedTime;
}
}
// 格式化时间显示
formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) {
return '00:00';
}
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
// 生成视频帧缩略图
async generateVideoThumbnails() {
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;
}
this.isGeneratingThumbnails = true;
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;
// 暂停视频
if (!video.paused) {
video.pause();
} }
// 逐个提取帧
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。');
}
// 恢复视频状态
if (!originalPaused) {
video.play();
} else {
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();
}
// 跳转到指定时间
seekToTime(video: HTMLVideoElement, time: number): Promise<void> {
return new Promise((resolve) => {
const onSeeked = () => {
video.removeEventListener('seeked', onSeeked);
resolve();
};
video.addEventListener('seeked', onSeeked);
video.currentTime = time;
});
}
// 点击缩略图跳转到对应时间
onThumbnailClick(time: number) {
this.fragmentSelectedTime = time;
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 || this.thumbnailCount <= 0) {
return 0;
}
if (index >= this.thumbnailCount) {
return this.fragmentVideoDuration;
}
// 使用与生成缩略图时相同的计算方式:每秒一张图
// 索引0对应0秒,索引1对应1秒,索引2对应2秒...
return index;
}
// 判断缩略图是否处于激活状态(当前时间在该帧范围内)
isThumbnailActive(index: number): boolean {
if (!this.fragmentVideoDuration || this.thumbnailCount <= 0) {
return false;
}
const thumbnailTime = this.getThumbnailTime(index);
// 由于每秒一张图,每个缩略图代表1秒的时间范围
// 索引i代表时间[i, i+1)秒,最后一个索引代表[lastIndex, duration]秒
// 使用向下取整来判断当前时间属于哪个缩略图
const selectedIndex = Math.floor(this.fragmentSelectedTime);
return selectedIndex === index;
}
initItem() {
}
templateTypeChange(e) { templateTypeChange(e) {
...@@ -706,12 +900,10 @@ export class FormComponent implements OnInit, OnChanges, OnDestroy { ...@@ -706,12 +900,10 @@ export class FormComponent implements OnInit, OnChanges, OnDestroy {
* 储存图片数据 * 储存图片数据
* @param e * @param e
*/ */
onImageUploadSuccess(e, key, item) { onImageUploadSuccess(e) {
if (!item) {
item = this.item; this.item.video_cover_pic = e.url;
}
item[key] = e.url;
this.save(); this.save();
} }
...@@ -917,14 +1109,6 @@ export class FormComponent implements OnInit, OnChanges, OnDestroy { ...@@ -917,14 +1109,6 @@ export class FormComponent implements OnInit, OnChanges, OnDestroy {
*/ */
save() { save() {
// if (this.tempItem) {
// this.item.tempItem = this.tempItem;
// }
// if (this.customTypeGroupArr) {
// this.item.customTypeGroupArr = this.customTypeGroupArr;
// }
(<any>window).courseware.setData(this.item, null, this.saveKey); (<any>window).courseware.setData(this.item, null, this.saveKey);
this.refresh(); this.refresh();
...@@ -943,4 +1127,129 @@ export class FormComponent implements OnInit, OnChanges, OnDestroy { ...@@ -943,4 +1127,129 @@ export class FormComponent implements OnInit, OnChanges, OnDestroy {
}, 1); }, 1);
} }
// 截图
handleOnCapture(e) {
console.log(e)
const href = URL.createObjectURL(e)
const box = document.createElement('a')
box.download = this.item.title ? this.item.title + ".jpg" : "未命名" + ".jpg"
box.href = href
box.click()
}
/**
* 储存图片数据
* @param e
*/
onImageUploadSuccess_new(e, index) {
this.item.dataArr[index].cover = e.url
this.save();
}
onVideoUploaded_new(e, index) {
this.item.dataArr[index].video_url = e.url
this.save();
}
/**
* 删除卡片
*/
onRemoveVideo() {
}
/** 添加卡片 */
onAddData() {
this.item.dataArr.push({
title: "",
cover: "",
video_url: ""
})
this.save()
}
onParentRef(e) {
const item = this.item;
if (!item.file) return;
const uploadXHRArgs:UploadXHRArgs={
file: item.file,
name:'file',
onProgress:(en,file)=>{
e.handleChange({
file: file,
fileList: [file],
type:"progress",
event:en
});
},
onSuccess(ret, file, xhr) {
e.handleChange({
file: {...file,response:ret},
fileList: [file],
type:"success",
});
},
}
e.customReq(uploadXHRArgs);
const upFile: UploadChangeParam = {
file: item.file,
fileList: [item.file],
type:"start"
};
e.handleChange(upFile);
}
onDragOver(event: DragEvent) {
event.preventDefault();
}
onDrop(event: DragEvent) {
event.preventDefault();
const items = event.dataTransfer?.items;
if (items) {
this.handleItems(items);
}
}
handleItems(items: DataTransferItemList) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === "file") {
const entry = item.webkitGetAsEntry();
this.traverseFileTree(entry,item);
}
}
}
traverseFileTree(item: any,entity?:DataTransferItem) {
if (item.isFile) {
const file = entity.getAsFile();
this.item.dataArr.push({
title: item.name.replace(`.mp4`,``),
cover: "",
video_url: "",
file: file,
batch:true
});
this.save()
// 处理文件,可以上传等操作
} else if (item.isDirectory) {
this.item.unitTitle = item.name;
const directoryReader = item.createReader();
directoryReader.readEntries((entries: any) => {
for (let i = 0; i < entries.length; i++) {
this.traverseFileTree(entries[i],entries[i]);
}
});
}
}
} }
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