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 16/** 17 * 视频剪辑时间长度选择组件 18 */ 19 20import CommonConstants from '../constants/Constants'; 21import { logger } from '../utils/Logger'; 22import { RangSeekBarListener } from './RangSeekBarListener'; 23import { RangSeekBarOption } from './RangSeekBarOption'; 24import { TimeUtils } from '../utils/TimeUtils'; 25 26const TAG: string = 'videoTrimmer_SeekBar'; 27 28/** 29 * 视频播放状态 30 */ 31export class MediaPlayerStatus { 32 public redLineProgress: number = 0; // [0,1] 33 public isPlaying: boolean = false; // true播放 false暂停 34} 35 36@Component 37export struct RangeSeekBarView { 38 // 裁剪选择区域变动时刷新界面 39 @Prop @Watch('watchRangSeekBarOption') mRangSeekBarOption: RangSeekBarOption; 40 41 watchRangSeekBarOption() { 42 this.configUI(); 43 this.initUIRange(); 44 } 45 46 // 播放状态发生变动时刷新界面 47 @Prop @Watch('watchMediaPlayerStatus') mMediaPlayerStatus: MediaPlayerStatus; 48 49 watchMediaPlayerStatus() { 50 if (this.mMediaPlayerStatus) { 51 this.showRedProgress = this.mMediaPlayerStatus.isPlaying; 52 this.touchEnable = !this.mMediaPlayerStatus.isPlaying; 53 this.redLinePosition = { 54 x: this.leftThumbRect[2] + this.transparentWidth * this.mMediaPlayerStatus.redLineProgress, 55 y: this.topPaddingWidth 56 }; 57 } 58 } 59 60 // 视频选取范围侦听回调 61 @Require mRangSeekBarListener?: RangSeekBarListener; 62 @State leftThumbWidth: number = 30; 63 @State rightThumbWidth: number = 30; 64 @State topPaddingWidth: number = 10; 65 @State bottomPaddingWidth: number = 10; 66 @State transparentWidth: number = 0; 67 // 可滑动组件的整体宽度 68 @State seekCompWidth: number = 0; 69 // 可滑动组件的整体高度 70 @State seekCompHeight: number = 0; 71 // 整个组件的宽度 72 componentMaxWidth: number = 0; 73 // 整个组件的高度 74 componentMaxHeight: number = 0; 75 // 侦听选择区域变动 76 @Prop @Watch('watchComponentRect') componentRect: Array<number> = []; 77 78 watchComponentRect() { 79 logger.info(TAG, 'watchComponentRect = ' + JSON.stringify(this.componentRect)); 80 } 81 82 // 侦听左边区域变动 83 @Prop @Watch('watchLeftThumbRect') leftThumbRect: Array<number> = []; 84 85 watchLeftThumbRect() { 86 logger.info(TAG, 'watchLeftThumb =' + JSON.stringify(this.leftThumbRect)); 87 } 88 89 // 侦听右边区域变动 90 @Prop @Watch('watchLeftThumbRect') rightThumbRect: Array<number> = []; 91 92 watchRightThumbRect() { 93 logger.info(TAG, 'watchRightThumbRect =' + JSON.stringify(this.rightThumbRect)) 94 } 95 96 // 触摸位置相关参数 97 touchLeftThumb = 1; 98 touchRightThumb = 2; 99 touchHorScroll = 3; 100 touchStatus: number = this.touchHorScroll; 101 touchXOld = 0; 102 touchDeltaX = 0; 103 @State leftThumbPosition: Position = { x: 0, y: 0 }; 104 @State rightThumbPosition: Position = { x: 0, y: 0 }; 105 @State middenPosition: Position = { x: 0, y: 0 }; 106 @State redLinePosition: Position = { x: 0, y: 0 }; 107 // 左边可滑动条状物 左边间距 108 leftPadding = 0; 109 // 右边可滑动条状物 右边间距 110 rightPadding = 0; 111 // 左右可滑动条状物 之间的最小间距 112 leftRightPaddingMin = 0; 113 // 左右可滑动条状物 之间的最大间距 114 leftRightPaddingMax = 0; 115 // 一秒钟所占的PX宽度 116 msPxAvg: number = 0; 117 scroller: Scroller = new Scroller(); 118 // 滑动速度 119 speed: number = 0; 120 // 播放状态不响应触碰 121 touchEnable: boolean = true; 122 // 手势相关参数界面配置 123 private panOption: PanGestureOptions = new PanGestureOptions({ direction: PanDirection.Left | PanDirection.Right }); 124 @State leftText: string = '00:00:00'; 125 @State rightText: string = '00:00:10'; 126 @State leftTextPosition: Position = { x: 0, y: 0 }; 127 @State rightTextPosition: Position = { x: 0, y: 0 }; 128 // 显示播放进度 129 @State showRedProgress: boolean = false; 130 leftTextWidth: number = 60; 131 rightTextWidth: number = 60; 132 133 aboutToAppear() { 134 this.configUI(); 135 } 136 137 build() { 138 Column() { 139 // 选取的时间范围界面 140 Stack() { 141 Text(this.leftText) 142 .id('txt_left_time') 143 .width(this.leftTextWidth) 144 .height($r('app.string.video_trimmer_full_size')) 145 .textAlign(TextAlign.Start) 146 .position(this.leftTextPosition) 147 .fontSize($r('app.integer.video_trimmer_font_size_12')) 148 .fontColor(Color.White) 149 150 Text(this.rightText) 151 .id('txt_right_time') 152 .width(this.rightTextWidth) 153 .height($r('app.string.video_trimmer_full_size')) 154 .textAlign(TextAlign.End) 155 .position(this.rightTextPosition) 156 .fontSize($r('app.integer.video_trimmer_font_size_12')) 157 .fontColor(Color.White) 158 } 159 .width(this.seekCompWidth) 160 .height($r('app.string.video_trimmer_rangeSeekBar_height')) 161 162 Stack() { 163 // 背景框 164 Flex({ justifyContent: FlexAlign.SpaceBetween, direction: FlexDirection.Column }) { 165 Row() { 166 } 167 .width($r('app.string.video_trimmer_full_size')) 168 .height(this.topPaddingWidth) 169 .backgroundColor(Color.White) 170 171 Row() { 172 } 173 .width($r('app.string.video_trimmer_full_size')) 174 .height(this.bottomPaddingWidth) 175 .backgroundColor(Color.White) 176 } 177 .width(this.transparentWidth) 178 .height($r('app.string.video_trimmer_full_size')) 179 .backgroundColor(Color.Transparent) 180 .position(this.middenPosition) 181 182 // 播放进度条 183 Stack() { 184 } 185 .width(1) 186 .height(this.seekCompHeight - this.topPaddingWidth - this.bottomPaddingWidth) 187 .backgroundColor(Color.Red) 188 .visibility(this.showRedProgress ? Visibility.Visible : Visibility.None) 189 .position(this.redLinePosition) 190 191 // 左右选择图片 192 Image($r('app.media.video_trimmer_thumb_handle')) 193 .id('left_thumb_handle') 194 .objectFit(ImageFit.Fill) 195 .width(this.leftThumbWidth) 196 .height($r('app.string.video_trimmer_full_size')) 197 .position(this.leftThumbPosition) 198 Image($r('app.media.video_trimmer_thumb_handle')) 199 .id('right_thumb_handle') 200 .objectFit(ImageFit.Fill) 201 .width(this.leftThumbWidth) 202 .height($r('app.string.video_trimmer_full_size')) 203 .position(this.rightThumbPosition) 204 } 205 .width(this.seekCompWidth) 206 .height('85%') 207 .onAreaChange((oldValue, newValue) => { 208 this.seekCompHeight = newValue.height as number; 209 }) 210 } 211 .width($r('app.string.video_trimmer_full_size')) 212 .height($r('app.string.video_trimmer_full_size')) 213 .enabled(this.touchEnable) 214 .onAreaChange((oldValue, newValue) => { 215 this.componentMaxWidth = newValue.width as number; 216 this.componentMaxHeight = newValue.height as number; 217 this.initUIRange(); 218 }) 219 // 绑定手势 220 .parallelGesture( 221 PanGesture(this.panOption)// 根据手势计算选择视频区间的时间位置 222 .onActionStart((event?: GestureEvent) => { 223 if (this.touchInThumb(event)) { 224 // 左边滑块移动 225 if (this.touchInLeftThumb(event)) { 226 this.touchStatus = this.touchLeftThumb; 227 } else if (this.touchInRightThumb(event)) { 228 // 右边滑块移动 229 this.touchStatus = this.touchRightThumb; 230 } 231 } 232 this.touchXOld = this.clearUndefined(event?.offsetX); 233 }) 234 .onActionUpdate((event?: GestureEvent) => { 235 let touchXNew: number = this.clearUndefined(event?.offsetX); 236 let delTax: number = touchXNew - this.touchXOld; 237 // 左边滑块移动 238 if (this.touchStatus == this.touchLeftThumb) { 239 this.leftThumbUpdate(delTax); 240 } else if (this.touchStatus == this.touchRightThumb) { 241 // 右边滑块移动 242 this.rightThumbUpdate(delTax); 243 } else if (this.touchStatus == this.touchHorScroll) { 244 this.scrollUpdate(delTax); 245 } 246 this.touchDeltaX = delTax; 247 this.touchXOld = this.clearUndefined(event?.offsetX); 248 }) 249 .onActionEnd((event?: GestureEvent) => { 250 this.touchStatus = this.touchHorScroll; 251 this.onRangeStopScrollChanged(); 252 }) 253 ) 254 .parallelGesture( 255 SwipeGesture({ direction: SwipeDirection.Horizontal }) 256 .onAction((event?: GestureEvent) => { 257 this.speed = this.clearUndefined(event?.speed); 258 259 logger.info(TAG, 'SwipeGesture onAction speed =' + this.speed); 260 }) 261 ) 262 } 263 264 // 配置界面UI位置 265 configUI() { 266 if (this.mRangSeekBarOption.mThumbWidth) { 267 this.leftThumbWidth = this.mRangSeekBarOption.mThumbWidth; 268 this.rightThumbWidth = this.mRangSeekBarOption.mThumbWidth; 269 } 270 if (this.mRangSeekBarOption.mPaddingTopBottom) { 271 this.topPaddingWidth = this.mRangSeekBarOption.mPaddingTopBottom; 272 this.bottomPaddingWidth = this.mRangSeekBarOption.mPaddingTopBottom; 273 } 274 } 275 276 // 初始化视频剪辑范围 277 initUIRange() { 278 this.componentRect = [0, 0, this.componentMaxWidth, this.componentMaxHeight]; 279 280 this.leftThumbRect = [0, 0, this.leftThumbWidth, this.componentMaxHeight]; 281 this.leftThumbPosition = { x: this.leftThumbRect[0], y: this.leftThumbRect[1] }; 282 this.leftTextPosition = { x: this.leftThumbRect[0], y: this.leftThumbRect[1] }; 283 284 if (this.mRangSeekBarOption) { 285 this.msPxAvg = ((this.componentMaxWidth - (this.leftThumbWidth + this.rightThumbWidth)) * 1000.0) / 286 this.mRangSeekBarOption.mMaxCountRange; 287 this.leftRightPaddingMax = 288 ((this.mRangSeekBarOption.mMaxShootTime * 1.0) / this.mRangSeekBarOption.mMaxCountRange) * 289 (this.componentMaxWidth - (this.leftThumbWidth + this.rightThumbWidth)); 290 this.leftRightPaddingMin = 291 ((this.mRangSeekBarOption.mMinShootTime * 1.0) / this.mRangSeekBarOption.mMaxCountRange) * 292 (this.componentMaxWidth - (this.leftThumbWidth + this.rightThumbWidth)); 293 } 294 295 // 获取当前右边界 296 let rightMax = this.mRangSeekBarOption.mRightProgressPos / 1000; 297 // 视频时间小于录制最大时间情况 298 if (rightMax <= this.mRangSeekBarOption.mMaxShootTime) { 299 // 最大距离需要重置为 视频时间 300 this.leftRightPaddingMax = ((rightMax * 1.0) / this.mRangSeekBarOption.mMaxCountRange) * 301 (this.componentMaxWidth - (this.leftThumbWidth + this.rightThumbWidth)); 302 } else { 303 rightMax = this.mRangSeekBarOption.mMaxShootTime; 304 } 305 306 // 由于mMaxShootTime <= mMaxCountRange 所以这个值必定在 当前页面内 307 let right2LeftDistance = rightMax * (this.msPxAvg / 1000.0); 308 309 this.rightThumbRect = 310 [this.leftThumbRect[2] + right2LeftDistance, 0, this.leftThumbRect[2] + right2LeftDistance + this.rightThumbWidth, 311 this.componentMaxHeight]; 312 this.rightThumbPosition = { x: this.rightThumbRect[0], y: this.rightThumbRect[1] }; 313 this.rightTextPosition = { x: this.rightThumbRect[2] - this.rightTextWidth, y: this.rightThumbRect[1] }; 314 315 this.seekCompWidth = this.componentMaxWidth; 316 this.middenPosition = { x: this.leftThumbRect[2], y: this.leftThumbRect[1] }; 317 this.transparentWidth = right2LeftDistance; 318 this.leftText = this.showThumbText(this.mRangSeekBarOption.mLeftProgressPos); 319 this.rightText = this.showThumbText(Math.min(this.mRangSeekBarOption.mRightProgressPos, 320 this.mRangSeekBarOption.mMaxShootTime * 1000)); 321 } 322 323 // 更新左边滑块位置 324 leftThumbUpdate(deltaX: number) { 325 let deltaPx = deltaX; 326 // 左边距 327 if (deltaPx <= 0 && ((this.leftThumbRect[0] + deltaPx) <= this.leftPadding)) { 328 deltaPx = this.leftPadding - this.leftThumbRect[0]; 329 } 330 // 右边距 331 if (deltaPx >= 0 && ((this.leftThumbRect[2] + deltaPx) >= (this.rightThumbRect[0] - this.leftRightPaddingMin))) { 332 deltaPx = (this.rightThumbRect[0] - this.leftRightPaddingMin) - this.leftThumbRect[2]; 333 } 334 // 左边界 新增与右thumb的最大边距 335 if (deltaPx <= 0 && ((this.leftThumbRect[2] + deltaPx) <= (this.rightThumbRect[0] - this.leftRightPaddingMax))) { 336 deltaPx = (this.rightThumbRect[0] - this.leftRightPaddingMax) - this.leftThumbRect[2]; 337 } 338 339 let newArea = [ 340 (this.leftThumbRect[0] + deltaPx), 341 this.leftThumbRect[1], 342 (this.leftThumbRect[2] + deltaPx), 343 this.leftThumbRect[3]]; 344 345 this.leftThumbRect = newArea; 346 this.leftThumbPosition = { x: newArea[0], y: newArea[1] }; 347 this.leftTextPosition = { x: newArea[0], y: newArea[1] }; 348 349 this.middenPosition = { x: this.leftThumbRect[2], y: this.leftThumbRect[1] }; 350 this.transparentWidth = this.rightThumbRect[0] - this.leftThumbRect[2]; 351 // 更新选取视频时间 352 this.onRangeValueChanged(); 353 } 354 355 // 调用时间转换函数显示时间 356 showThumbText(time: number): string { 357 return TimeUtils.msToHHMMSS(time); 358 } 359 360 // 选取时间变动事件 361 onRangeValueChanged() { 362 let x0: number = this.scroller.currentOffset().xOffset; 363 let start: number = x0 + this.leftThumbRect[2] - this.leftThumbWidth; 364 let end: number = start + this.transparentWidth; 365 366 let startTime: number = start * CommonConstants.US_ONE_SECOND / this.msPxAvg; 367 this.leftText = this.showThumbText(startTime); 368 let endTime: number = end * CommonConstants.US_ONE_SECOND / this.msPxAvg; 369 this.rightText = this.showThumbText(endTime); 370 371 if (this.mRangSeekBarListener) { 372 this.mRangSeekBarListener.onRangeSeekBarValuesChanged(startTime, endTime); 373 } 374 } 375 376 // 视频范围选择结束 377 onRangeStopScrollChanged() { 378 let start: number = this.scroller.currentOffset().xOffset; 379 // 计算开始位置 380 let startPosition: number = start * 1000.0 / this.msPxAvg 381 startPosition = Math.max(0, Math.floor(startPosition)); 382 383 // 计算结束位置 384 let endPosition: number = startPosition + this.mRangSeekBarOption.mMaxCountRange + 5; 385 let maxEnd: number = Math.floor(this.mRangSeekBarOption.mRightProgressPos / 1000); 386 endPosition = Math.min(maxEnd, endPosition); 387 388 if (this.mRangSeekBarListener) { 389 this.mRangSeekBarListener.onRangeSeekBarScrollChanged(startPosition, endPosition); 390 } 391 } 392 393 // 更新右边滑块位置 394 rightThumbUpdate(deltaX: number) { 395 let deltaPx = deltaX; 396 // 右边距 397 if (deltaPx >= 0 && ((this.rightThumbRect[2] + deltaPx) >= this.componentMaxWidth - this.rightPadding)) { 398 deltaPx = this.componentMaxWidth - this.rightPadding - this.rightThumbRect[2]; 399 } 400 // 左边距 401 if (deltaPx <= 0 && ((this.rightThumbRect[0] + deltaPx) <= (this.leftThumbRect[2] + this.leftRightPaddingMin))) { 402 deltaPx = (this.leftThumbRect[2] + this.leftRightPaddingMin) - this.rightThumbRect[0]; 403 } 404 // 右边距 新增 与leftThumb最大距离 405 if (deltaPx >= 0 && ((this.rightThumbRect[0] + deltaPx) >= (this.leftThumbRect[2] + this.leftRightPaddingMax))) { 406 deltaPx = (this.leftThumbRect[2] + this.leftRightPaddingMax) - this.rightThumbRect[0]; 407 } 408 409 let newArea = [ 410 (this.rightThumbRect[0] + deltaPx), 411 this.rightThumbRect[1], 412 (this.rightThumbRect[2] + deltaPx), 413 this.rightThumbRect[3]]; 414 415 this.rightThumbRect = newArea; 416 this.rightThumbPosition = { x: newArea[0], y: newArea[1] }; 417 this.rightTextPosition = { x: newArea[2] - this.rightTextWidth, y: newArea[1] }; 418 419 this.middenPosition = { x: this.leftThumbRect[2], y: this.leftThumbRect[1] } 420 this.transparentWidth = this.rightThumbRect[0] - this.leftThumbRect[2]; 421 // 更新选取视频时间 422 this.onRangeValueChanged(); 423 } 424 425 scrollUpdate(deltaX: number) { 426 let deltaPx = -deltaX; 427 this.scroller.scrollBy(deltaPx, 0); 428 this.onRangeValueChanged(); 429 } 430 431 // 判断触碰事件有效 432 touchInThumb(event?: GestureEvent): boolean { 433 if (this.touchInLeftThumb(event) || this.touchInRightThumb(event)) { 434 return true; 435 } else { 436 return false; 437 } 438 } 439 440 // 判断左边滑块滑动 441 touchInLeftThumb(event?: GestureEvent): boolean { 442 443 logger.info(TAG, ' touchInLeftThumb'); 444 let pointX: number = this.clearUndefined(event?.fingerList[0].localX); 445 let pointY: number = this.clearUndefined(event?.fingerList[0].localY); 446 return this.pointInArea(pointX, pointY, this.leftThumbRect); 447 } 448 449 // 判断右边滑块滑动 450 touchInRightThumb(event?: GestureEvent): boolean { 451 452 logger.info(TAG, ' touchInRightThumb'); 453 let pointX: number = this.clearUndefined(event?.fingerList[0].localX) 454 let pointY: number = this.clearUndefined(event?.fingerList[0].localY) 455 return this.pointInArea(pointX, pointY, this.rightThumbRect); 456 } 457 458 // 区域判断 459 pointInArea(x: number, y: number, area: Array<number>): boolean { 460 461 logger.info(TAG, ' x=' + x + ' y=' + y + ' area=' + area) 462 if (area.length == 4) { 463 if (x >= area[0] && x <= area[2]) { 464 return true; 465 } else { 466 return false; 467 } 468 } else { 469 return false; 470 } 471 } 472 473 // 判断位置坐标值 474 clearUndefined(num: number | undefined) { 475 if (num == undefined) { 476 return 0; 477 } 478 return num; 479 } 480}