1/* 2 * Copyright (c) 2025 Huawei Device Co., Ltd. 3 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * you may not use this file except in compliance with the License. 5 * You may obtain a copy of the License at 6 * 7 * http://www.apache.org/licenses/LICENSE-2.0 8 * 9 * Unless required by applicable law or agreed to in writing, software 10 * distributed under the License is distributed on an "AS IS" BASIS, 11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 * See the License for the specific language governing permissions and 13 * limitations under the License. 14 */ 15 16import image from '@ohos.multimedia.image'; 17import fileUri from '@ohos.file.fileuri'; 18import fs from '@ohos.file.fs'; 19import router from '@ohos.router'; 20import { common } from '@kit.AbilityKit'; 21import { promptAction } from '@kit.ArkUI'; 22// mp4parser 三方库组件 23import { IFrameCallBack, ICallBack, MP4Parser } from '@ohos/mp4parser'; 24import CommonConstants from '../constants/Constants'; 25 26import { CustomSetServerDlg } from '../customcomponents/CustomSetServerDlg'; 27import { CustomLoadingProgressDlg } from '../customcomponents/CustomLoadingProgressDlg'; 28import { TimeUtils } from '../utils/TimeUtils'; 29import { logger } from '../utils/Logger'; 30import { getTimeStr, savePixelMap } from '../utils/FileUtil'; 31import { requestUpload } from '../uploadanddownload/RequestUpload'; 32import { urlUtils } from '../utils/UrlUtils'; 33import { VideoPlayListener } from '../videoplaycomponents/VideoPlayListener'; 34import { VideoTrimmerOption } from '../videotrimmer/VideoTrimmerOption'; 35import { VideoTrimListener } from '../videotrimmer/VideoTrimListener'; 36import { VideoLoadFramesListener } from '../videotrimmer/VideoLoadFramesListener'; 37import { VideoTrimmerView } from '../videotrimmer/VideoTrimmerView'; 38import { WorkItem } from '../model/WorkItemModel'; 39 40const TAG: string = 'videoTrimmer_upload'; 41 42@Entry 43@Component 44export struct VideoUpload { 45 @State centerIndex: number = 0; // List显示区域内中间子组件索引值 46 @State isTrimmer: boolean = false; // 是否处于剪辑状态 47 private uploadTimesMax: number = 5; // 上传重试次数 48 @State serverUrl: string = ''; // 服务器地址 49 @State workItem: WorkItem = new WorkItem('1月3日', '15:40', '', '', '北高峰', true); 50 @State mVideoPlayListener: VideoPlayListener = { 51 onPrepared: () => { 52 }, 53 onComplete() { 54 }, 55 onPlayStatus: (isPlay: boolean) => { 56 }, 57 onTimeUpdate(time: number) { 58 this.currentTime = time; 59 }, 60 onBitrateUpdate(bitrateList: number[]) { 61 }, 62 onErrorUpdate(error: string) { 63 }, 64 } 65 @State mVideoTrimmerOption: VideoTrimmerOption = new VideoTrimmerOption(); 66 67 /** 68 * 获取视频第一张图片 69 */ 70 async getVideoFirstImage(videoSrc: string): Promise<void> { 71 let callBack: ICallBack = { 72 // 回调函数 73 callBackResult: (code: number) => { 74 if (code === 0) { 75 let frameCallBack: IFrameCallBack = { 76 callBackResult: async (data: ArrayBuffer, timeUs: number) => { 77 const imageSource: image.ImageSource = image.createImageSource(data); 78 79 let decodingOptions: image.DecodingOptions = { 80 sampleSize: 1, 81 editable: true, 82 desiredSize: { width: CommonConstants.firstImageWidth, height: CommonConstants.firstImageHeight }, 83 desiredPixelFormat: image.PixelMapFormat.RGBA_8888 84 }; 85 await imageSource.createPixelMap(decodingOptions).then(async (px: image.PixelMap) => { 86 this.workItem.firstImage = px; 87 imageSource.release(); 88 }) 89 } 90 } 91 let startTimeUs = CommonConstants.FIRST_IMAGE_START_TIME + ''; 92 let endTimeUs = CommonConstants.FIRST_IMAGE_END_TIME + ''; 93 // TODO: 知识点:传入起始时间,通过MP4Parser获取视频的首页图片 94 MP4Parser.getFrameAtTimeRang(startTimeUs, endTimeUs, MP4Parser.OPTION_CLOSEST, frameCallBack); 95 } 96 } 97 } 98 // TODO: 知识点:设置MP4Parser视频源地址及回调函数 99 MP4Parser.setDataSource(videoSrc, callBack); 100 } 101 102 // 配置服务器地址 103 customSetServerDlg: CustomDialogController = new CustomDialogController({ 104 builder: CustomSetServerDlg({ 105 serverUrl: getContext(this) 106 .resourceManager 107 .getStringSync($r('app.string.video_trimmer_default_serverIP')) 108 }), 109 alignment: DialogAlignment.Center, // 自定义弹窗底端对齐 110 autoCancel: true, 111 cancel: this.onCancel, // 返回、ESC键和点击遮障层弹窗退出时回调 112 customStyle: true, // 弹窗样式自定义 113 }) 114 115 // 返回、ESC键和点击遮障层弹窗退出时回调 116 onCancel(): void { 117 this.customSetServerDlg.close(); 118 } 119 120 // 校验是否配置服务器 121 async checkServerAddress(): Promise<boolean> { 122 // 获取服务器地址 123 this.serverUrl = await urlUtils.getUrl(getContext(this) as common.UIAbilityContext); 124 if (this.serverUrl === undefined || this.serverUrl === '') { 125 await this.customSetServerDlg.open(); 126 } 127 this.serverUrl = await urlUtils.getUrl(getContext(this) as common.UIAbilityContext); 128 logger.info(TAG, 'serverUrl is = ' + this.serverUrl); 129 if (this.serverUrl !== undefined && this.serverUrl.length > 1) { 130 return true; 131 } else { 132 return false; 133 } 134 } 135 136 // 保存首页背景图 137 async saveFirstImage() { 138 const url = await savePixelMap(getContext(this), this.workItem.firstImage as PixelMap, getTimeStr()); 139 this.workItem.firstImage = fileUri.getUriFromPath(url); 140 this.workItem.date = TimeUtils.getCurrentTime(); 141 AppStorage.setOrCreate('addWorkItem', this.workItem); 142 await this.customSetServerDlg.close(); 143 router.back(); 144 } 145 146 // TODO: 知识点: 保存视频到服务器 147 async uploadFiles(outVideoPath: string) { 148 // 校验是否配置服务器 149 if (!await this.checkServerAddress()) { 150 logger.error(TAG, 'serverUrl is null. '); 151 return; 152 } 153 154 let countdown: number = 0; // 重试计数 155 let imageList: string[] = []; 156 imageList.push(outVideoPath); 157 await requestUpload.uploadFiles(imageList, (progress: number, isSucceed: boolean) => { 158 progress = progress; 159 if (progress === CommonConstants.PROGRESS_MAX && isSucceed) { 160 logger.info(TAG, 'RequestUpload success. '); 161 // 上传成功后删除裁剪的视频 162 if (this.workItem.trimmerSrc !== '') { 163 fs.unlinkSync(this.workItem.trimmerSrc); 164 } 165 // 视频地址替换为服务器上的视频 166 this.workItem.videoSrc = this.serverUrl + this.workItem.videoSrc.split('/').pop(); 167 // 保存视频首页背景图 168 this.saveFirstImage(); 169 } 170 if (progress === CommonConstants.PROGRESS_MAX && isSucceed === false) { 171 countdown = this.uploadTimesMax; 172 let interval = setInterval(() => { 173 if (countdown > 0) { 174 countdown--; 175 } else { 176 clearInterval(interval); 177 } 178 }, CommonConstants.UPLOAD_INTERVAL_TIME); 179 promptAction.showToast({ message: $r('app.string.video_trimmer_upload_fail') }) 180 } 181 }); 182 } 183 184 async aboutToAppear(): Promise<void> { 185 const params = router.getParams() as Record<string, string>; // 获取传递过来的参数对象 186 if (params) { 187 this.workItem.videoSrc = params.value as string; // 获取参数value的值 188 this.workItem.trimmerSrc = ''; 189 logger.info(TAG, 'the source video path is:' + this.workItem.videoSrc); 190 // 获取视频第一张图片 191 await this.getVideoFirstImage(this.workItem.videoSrc); 192 // 视频剪辑参数选项 193 let tempOption = new VideoTrimmerOption(); 194 tempOption.listener = this.initListener; 195 tempOption.loadFrameListener = this.initLoadFrameListener; 196 tempOption.srcFilePath = this.workItem.videoSrc; 197 this.mVideoTrimmerOption = tempOption; 198 } 199 } 200 201 async aboutToDisappear(): Promise<void> { 202 fs.unlinkSync(this.mVideoTrimmerOption.srcFilePath); 203 this.customSetServerDlg.close(); 204 } 205 206 // 加载进度对话框 207 dialogController: CustomDialogController = new CustomDialogController({ 208 builder: CustomLoadingProgressDlg({}), 209 autoCancel: false, 210 alignment: DialogAlignment.Center, 211 offset: { dx: 0, dy: 0 }, 212 customStyle: false 213 }) 214 // 视频剪辑回调接口接口 215 initListener: VideoTrimListener = { 216 onStartTrim: () => { 217 logger.info(TAG, '开始裁剪'); 218 this.dialogController.open(); 219 }, 220 onFinishTrim: async (outVideoPath: string) => { 221 this.dialogController.close(); 222 // 更新上传视频地址 223 this.workItem.trimmerSrc = outVideoPath; 224 await this.getVideoFirstImage(this.workItem.trimmerSrc); 225 this.isTrimmer = false; 226 logger.info(TAG, '裁剪成功 path=' + this.workItem.trimmerSrc) 227 }, 228 onCancel: () => { 229 logger.info(TAG, '用户取消'); 230 this.isTrimmer = false; 231 } 232 } 233 // 视频帧加载回调接口 234 initLoadFrameListener: VideoLoadFramesListener = { 235 onStartLoad: () => { 236 logger.info(TAG, '开始获取帧数据') 237 }, 238 onFinishLoad: () => { 239 logger.info(TAG, '获取帧数据结束') 240 } 241 } 242 243 build() { 244 Column() { 245 if (!this.isTrimmer) { 246 this.VideoUpdateInfo(); 247 } else { 248 // TODO: 知识点: 视频剪辑组件VideoTrimmerView。 videoTrimmerOption:视频剪辑相关参数 249 VideoTrimmerView({ videoTrimmerOption: this.mVideoTrimmerOption!! }) 250 } 251 } 252 .backgroundColor(Color.Black) 253 .width($r('app.string.video_trimmer_full_size')) 254 .height($r('app.string.video_trimmer_full_size')) 255 } 256 257 /** 258 * 视频上传信息 259 */ 260 @Builder 261 VideoUpdateInfo() { 262 Row() { 263 // 左边取消按钮 264 Text($r('app.string.video_trimmer_cancel')) 265 .height($r('app.integer.video_trimmer_upload_info_height')) 266 .fontColor(Color.White) 267 .margin({ 268 left: $r('app.integer.video_trimmer_component_video_playing_image_margin_left') 269 }) 270 .id('upload_cancel') 271 .alignSelf(ItemAlign.Center) 272 .onClick(() => { 273 promptAction.showDialog({ 274 alignment: DialogAlignment.Center, 275 isModal: false, 276 message: $r('app.string.video_trimmer_cancel_confirm'), 277 buttons: [ 278 { 279 text: $r('app.string.video_trimmer_cancel'), 280 color: $r('app.color.video_trimmer_cancel') 281 }, 282 { 283 text: $r('app.string.video_trimmer_ok'), 284 color: $r('app.color.video_trimmer_confirm') 285 } 286 ] 287 }, async (err, data) => { 288 if (err) { 289 logger.error(TAG, 'showDialog err: ' + err); 290 return; 291 } 292 if (data.index === 1) { 293 // 确认上传视频 294 router.back(); 295 } 296 }); 297 }) 298 Blank() 299 // 右边保存按钮 300 Text($r('app.string.video_trimmer_save')) 301 .id('upload_save') 302 .width($r('app.integer.video_trimmer_update_save_width')) 303 .height($r('app.integer.video_trimmer_upload_info_height')) 304 .fontColor(Color.White) 305 .backgroundColor($r('app.color.video_trimmer_save_background')) 306 .margin({ 307 right: $r('app.integer.video_trimmer_component_video_playing_image_margin_left') 308 }) 309 .textAlign(TextAlign.Center) 310 .borderRadius($r('app.integer.video_trimmer_share_borderRadius')) 311 .onClick(async () => { 312 this.workItem.date = TimeUtils.getCurrentTime(); 313 // 剪辑成功后上传剪辑的视频,否则上传原视频 314 if (this.workItem.trimmerSrc != '') { 315 this.uploadFiles(this.workItem.trimmerSrc); 316 } else { 317 this.uploadFiles(this.workItem.videoSrc); 318 } 319 }) 320 } 321 .alignItems(VerticalAlign.Center) 322 .height($r('app.integer.video_trimmer_upload_top_height')) 323 .width($r('app.string.video_trimmer_full_size')) 324 325 // 背景图 326 Stack({ alignContent: Alignment.Center }) { 327 Image(this.workItem.firstImage) 328 .id('image_to_trimmer') 329 .height($r('app.integer.video_trimmer_upload_video_height')) 330 .onClick(() => { 331 if (this.workItem.trimmerSrc !== '') { 332 fs.unlinkSync(this.workItem.trimmerSrc); 333 this.workItem.trimmerSrc = ''; 334 } 335 this.isTrimmer = true; 336 }) 337 // 播放按钮 338 Image($r('app.media.video_trimmer_icon_pause')) 339 .width($r('app.integer.video_trimmer_component_video_playing_image_width')) 340 .height($r('app.integer.video_trimmer_component_video_playing_image_width')) 341 .onClick(() => { 342 this.isTrimmer = true; 343 }) 344 } 345 .margin({ 346 left: $r('app.integer.video_trimmer_upload_video_left_margin'), 347 right: $r('app.integer.video_trimmer_upload_video_left_margin'), 348 top: $r('app.integer.video_trimmer_upload_video_top_margin') 349 }) 350 351 Row() { 352 Text($r('app.string.video_trimmer_labels')) 353 .id('txtLabels') 354 .width($r('app.integer.video_trimmer_update_button_width')) 355 .textAlign(TextAlign.Center) 356 .fontColor(Color.White) 357 .borderRadius($r('app.integer.video_trimmer_upload_label_radius')) 358 .height($r('app.integer.video_trimmer_upload_info_height')) 359 .backgroundColor($r('app.color.video_trimmer_label_color')) 360 Text($r('app.string.video_trimmer_btn_label')) 361 .width($r('app.integer.video_trimmer_update_button_width')) 362 .textAlign(TextAlign.Center) 363 .fontColor(Color.White) 364 .margin({ left: $r('app.integer.video_trimmer_upload_label_margin') }) 365 .height($r('app.integer.video_trimmer_upload_info_height')) 366 .borderRadius($r('app.integer.video_trimmer_upload_label_radius')) 367 .backgroundColor(Color.Gray) 368 .onClick(() => { 369 promptAction.showToast({ message: $r('app.string.video_trimmer_toast_tips') }); 370 }) 371 }.height($r('app.integer.video_trimmer_upload_label_row_height')) 372 .margin({ 373 left: $r('app.integer.video_trimmer_lazy_foreach_list_page_list_item_margin_bottom'), 374 top: $r('app.integer.video_trimmer_upload_label_padding_top'), 375 }) 376 .alignSelf(ItemAlign.Start) 377 .align(Alignment.Start) 378 379 Row() { 380 Image($r('app.media.video_trimmer_address')) 381 .backgroundColor(Color.Black) 382 .width($r('app.integer.video_trimmer_update_icon_size')) 383 .height($r('app.integer.video_trimmer_update_icon_size')) 384 Text($r('app.string.video_trimmer_user_address')) 385 .id('txtAddress') 386 .padding({ left: $r('app.integer.video_trimmer_detail_ack_button_left_padding') }) 387 .fontColor(Color.White) 388 .textAlign(TextAlign.Center) 389 .onClick(() => { 390 promptAction.showToast({ message: $r('app.string.video_trimmer_toast_tips') }); 391 }) 392 } 393 .height($r('app.integer.video_trimmer_update_item_height')) 394 .width($r('app.string.video_trimmer_full_size')) 395 .padding({ left: $r('app.integer.video_trimmer_upload_detail_left_padding') }) 396 397 Row() { 398 Image($r('app.media.video_trimmer_user_lock')) 399 .width($r('app.integer.video_trimmer_update_icon_size')) 400 .height($r('app.integer.video_trimmer_update_icon_size')) 401 .backgroundColor(Color.Black) 402 Text($r('app.string.video_trimmer_who_can_see')) 403 .padding({ left: $r('app.integer.video_trimmer_detail_ack_button_left_padding') }) 404 .textAlign(TextAlign.Center) 405 .fontColor(Color.White) 406 Blank() 407 Text($r('app.string.video_trimmer_all_user')) 408 .id('txtUserCanView') 409 .textAlign(TextAlign.Center) 410 .fontColor(Color.White) 411 .margin({ right: $r('app.integer.video_trimmer_detail_back_margin') }) 412 .align(Alignment.End) 413 .onClick(() => { 414 promptAction.showToast({ message: $r('app.string.video_trimmer_toast_tips') }); 415 }) 416 } 417 .height($r('app.integer.video_trimmer_update_item_height')) 418 .width($r('app.string.video_trimmer_full_size')) 419 .padding({ left: $r('app.integer.video_trimmer_upload_detail_left_padding') }) 420 421 Row() { 422 Image($r('app.media.video_trimmer_record_time')) 423 .backgroundColor(Color.Black) 424 .width($r('app.integer.video_trimmer_update_icon_size')) 425 .height($r('app.integer.video_trimmer_update_icon_size')) 426 .alignSelf(ItemAlign.Center) 427 .align(Alignment.Center) 428 Text($r('app.string.video_trimmer_record_time')) 429 .padding({ left: $r('app.integer.video_trimmer_detail_ack_button_left_padding') }) 430 .textAlign(TextAlign.Center) 431 .fontColor(Color.White) 432 Blank() 433 Text(TimeUtils.getCurrentTime() + ' >') 434 .id('txtRecordTime') 435 .margin({ right: $r('app.integer.video_trimmer_detail_back_margin') }) 436 .textAlign(TextAlign.Center) 437 .fontColor(Color.White) 438 .align(Alignment.End) 439 } 440 .height($r('app.integer.video_trimmer_update_item_height')) 441 .width($r('app.string.video_trimmer_full_size')) 442 .padding({ left: $r('app.integer.video_trimmer_upload_detail_left_padding') }) 443 .margin({ bottom: $r('app.integer.video_trimmer_upload_detail_bottom_margin') }) 444 } 445}