1/* 2 * Copyright (c) 2022-2023 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 Curves from '@ohos.curves'; 17import { Log } from '../utils/Log'; 18import { Constants } from '../model/common/Constants'; 19import { Constants as PhotoConstants } from '../model/browser/photo/Constants'; 20import { MediaItem } from '../model/browser/photo/MediaItem'; 21import { DateUtil } from '../utils/DateUtil'; 22import { BroadCast } from '../utils/BroadCast'; 23import { BroadCastConstants } from '../model/common/BroadCastConstants'; 24import { Action } from './browserOperation/Action'; 25import { ImageUtil } from '../utils/ImageUtil'; 26import { ColumnSize, ScreenManager } from '../model/common/ScreenManager'; 27import { TraceControllerUtils } from '../utils/TraceControllerUtils'; 28import { UserFileManagerAccess } from '../access/UserFileManagerAccess'; 29import { MultimodalInputManager } from '../model/common/MultimodalInputManager'; 30import { BigDataConstants, ReportToBigDataUtil } from '../utils/ReportToBigDataUtil'; 31import { AlbumDefine } from '../model/browser/AlbumDefine'; 32import { MediaDataSource } from '../model/browser/photo/MediaDataSource'; 33import { BroadCastManager } from '../model/common/BroadCastManager'; 34 35const TAG: string = 'common_ImageGridItemComponent'; 36 37@Extend(Image) function focusSetting(uri: string, handleEvent: Function) { 38 .key('ImageGridFocus_' + uri) 39 .focusable(true) 40 .onKeyEvent((event: KeyEvent) => { 41 handleEvent(event); 42 }) 43} 44 45// General grid picture control 46@Component 47export struct ImageGridItemComponent { 48 item: MediaItem; 49 @StorageLink('isHorizontal') isHorizontal: boolean = ScreenManager.getInstance().isHorizontal(); 50 @Consume @Watch('onModeChange') isSelectedMode: boolean; 51 @State isSelected: boolean = false; 52 isRecycle: boolean = false; 53 @Consume broadCast: BroadCast; 54 @Consume @Watch('onShow') isShow: boolean; 55 @Link selectedCount: number; 56 @State autoResize: boolean = true; 57 loaded = false; 58 mPosition: number; 59 pageName = ''; 60 @State isLoadImageError: boolean = false; 61 @State pressAnimScale: number = 1.0; 62 @State recycleDays: number = 0; 63 @Consume rightClickMenuList: Array<Action>; 64 onMenuClicked: Function; 65 onMenuClickedForSingleItem: Function; 66 @State geometryTransitionString: string = 'default_id'; 67 @State isTap: boolean = false; 68 @StorageLink('placeholderIndex') @Watch('verifyTapStatus') placeholderIndex: number = -1; 69 @StorageLink('geometryTransitionBrowserId') @Watch('verifyTapStatus') geometryTransitionBrowserId: string = ''; 70 private imageThumbnail: string = undefined; 71 private transitionId: string; 72 private isEnteringPhoto = false; 73 private isThird = false; 74 private isThirdMultiPick: boolean = false; 75 private OPTS = { 76 'sampleSize': 1, 77 'rotateDegrees': 0, 78 'editable': false, 79 'desiredSize': { 80 'width': 0, 81 'height': 0 82 }, 83 'desiredRegion': { 84 'size': { 85 'width': 0, 86 'height': 0 87 }, 88 'x': 0, 89 'y': 0 90 }, 91 'desiredPixelFormat': 3, 92 }; 93 private albumUri: string = ''; 94 private dataSource: MediaDataSource; 95 private geometryTapIndex: number; 96 private isTapStatusChange: boolean = false; 97 98 verifyTapStatus() { 99 if (this.placeholderIndex === Constants.INVALID) { 100 this.isTap = false; 101 return; 102 } 103 this.updateGeometryTapInfo(); 104 let pageFromGlobal = this.geometryTransitionBrowserId.split(':')[0]; 105 let pageFrom = this.geometryTransitionString.split(':')[0]; 106 let oldTapStatus = this.isTap; 107 let newTapStatus = (pageFromGlobal === pageFrom) && (this.placeholderIndex === this.geometryTapIndex); 108 this.isTapStatusChange = oldTapStatus !== newTapStatus; 109 this.isTap = newTapStatus; 110 if (this.isTap) { 111 this.geometryTransitionString = this.geometryTransitionBrowserId; 112 Log.debug(TAG, 'update placeholderIndex = ' + this.placeholderIndex + 113 'geometryTapIndex = ' + this.geometryTapIndex + ', isTap = ' + this.isTap + 114 ', geometryTransitionString = ' + this.geometryTransitionString); 115 } 116 } 117 118 aboutToAppear(): void { 119 this.imageThumbnail = this.item?.thumbnail; 120 this.albumUri = AppStorage.Get<string>(Constants.KEY_OF_ALBUM_URI); 121 if (this.isSelected) { 122 this.transitionId = `${this.item.hashCode}_${this.albumUri}_${this.isSelected}`; 123 } else { 124 this.transitionId = `${this.item.hashCode}_${this.albumUri}`; 125 } 126 if (this.isRecycle) { 127 this.calculateRecycleDays(); 128 } 129 Log.info(TAG, `transitionId: ${this.transitionId}`); 130 this.isTap = this.geometryTransitionString === this.geometryTransitionBrowserId; 131 } 132 133 aboutToDisappear(): void { 134 Log.debug(TAG, `aboutToDisappear: ${this.item.uri}`); 135 this.resetPressAnim(); 136 } 137 138 onModeChange(newMode: boolean): void { 139 Log.debug(TAG, `newMode ${newMode}`); 140 if (!this.isSelectedMode) { 141 this.isSelected = false; 142 } 143 } 144 145 onAllSelect(newMode: boolean): boolean { 146 Log.debug(TAG, `onAllSelect ${newMode}`); 147 return newMode; 148 } 149 150 async routePage(isError: boolean) { 151 Log.info(TAG, `routePage ${isError}`); 152 try { 153 TraceControllerUtils.startTrace('enterPhotoBrowser'); 154 if (this.isThird) { 155 this.broadCast.emit(BroadCastConstants.JUMP_THIRD_PHOTO_BROWSER, [this.pageName, this.item]); 156 } else { 157 this.broadCast.emit(BroadCastConstants.JUMP_PHOTO_BROWSER, [this.pageName, this.item]); 158 } 159 } catch (err) { 160 Log.error(TAG, `fail callback, code: ${err.code}, msg: ${err.msg}`); 161 } 162 } 163 164 async routeToPreviewPage() { 165 try { 166 Log.info(TAG, 'routeToPreviewPage'); 167 this.updateGeometryTapInfo(); 168 this.broadCast.emit(BroadCastConstants.JUMP_THIRD_PHOTO_BROWSER, 169 [this.pageName, this.item, this.geometryTapIndex, this.geometryTransitionString]); 170 } catch (err) { 171 Log.error(TAG, `fail callback, code: ${err.code}, msg: ${err.msg}`); 172 } 173 } 174 175 selectStateChange() { 176 Log.info(TAG, 'change selected.'); 177 let newState = !this.isSelected; 178 AppStorage.SetOrCreate('focusUpdate', true); 179 if (this.item.uri) { 180 this.mPosition = this.getPosition(); 181 this.broadCast.emit(BroadCastConstants.SELECT, [this.mPosition, this.item.uri, newState, function(isSelected){ 182 Log.info(TAG, `enter callback, select status ${this.mPosition} ${this.item.uri} ${newState} ${this.isSelected}`); 183 this.isSelected = isSelected == undefined ? newState : isSelected; 184 }.bind(this)]); 185 } 186 } 187 188 @Builder RightClickMenuBuilder() { 189 Column() { 190 ForEach(this.rightClickMenuList, (menu: Action) => { 191 Text(this.changeTextResToPlural(menu)) 192 .key('RightClick_' + this.mPosition + menu.componentKey) 193 .width('100%') 194 .height($r('app.float.menu_height')) 195 .fontColor(menu.fillColor) 196 .fontSize($r('sys.float.ohos_id_text_size_body1')) 197 .fontWeight(FontWeight.Regular) 198 .maxLines(2) 199 .textOverflow({ overflow: TextOverflow.Ellipsis }) 200 .onClick(() => { 201 Log.info(TAG, 'on right click menu, action: ' + menu.actionID); 202 if (menu == Action.MULTISELECT) { 203 this.selectStateChange(); 204 } else { 205 // 1.当鼠标对着被选中的项按右键时,菜单中的功能,针对所有被选中的项做处理 206 // 2.当鼠标对着未被选中的项按右键时,菜单中的功能,仅针对当前项处理,其他被选中的项不做任何处理 207 if (this.isSelectedMode && this.isSelected) { 208 this.onMenuClicked && this.onMenuClicked(menu); 209 } else { 210 this.onMenuClickedForSingleItem && this.onMenuClickedForSingleItem(menu, this.item); 211 } 212 } 213 }) 214 }, menu => menu.actionID.toString()) 215 } 216 .width(ScreenManager.getInstance().getColumnsWidth(ColumnSize.COLUMN_TWO)) 217 .borderRadius($r('sys.float.ohos_id_corner_radius_card')) 218 .padding({ 219 top: $r('app.float.menu_padding_vertical'), 220 bottom: $r('app.float.menu_padding_vertical'), 221 left: $r('app.float.menu_padding_horizontal'), 222 right: $r('app.float.menu_padding_horizontal') 223 }) 224 .backgroundColor(Color.White) 225 .margin({ 226 right: $r('sys.float.ohos_id_max_padding_end'), 227 bottom: $r('app.float.menu_margin_bottom') 228 }) 229 } 230 231 build() { 232 Column() { 233 if (this.isTap) { 234 Column() { 235 } 236 .aspectRatio(1) 237 .rotate({ x: 0, y: 0, z: 1, angle: 0 }) 238 .backgroundColor($r('app.color.default_background_color')) 239 .width("100%") 240 .height("100%") 241 .zIndex(-1) 242 } else { 243 this.buildNormal() 244 } 245 } 246 } 247 248 @Builder buildImage() { 249 Image(this.imageThumbnail) 250 .width('100%') 251 .height('100%') 252 .rotate({ x: 0, y: 0, z: 1, angle: 0 }) 253 .objectFit(ImageFit.Cover) 254 .autoResize(false) 255 .focusSetting(this.item.uri, this.handleKeyEvent.bind(this)) 256 .onError(() => { 257 this.isLoadImageError = true; 258 AppStorage.SetOrCreate('focusUpdate', true); 259 Log.error(TAG, 'item Image error'); 260 }) 261 .onComplete(() => { 262 Log.debug(TAG, `Draw the image! ${this.imageThumbnail}`); 263 }) 264 .onAppear(() => { 265 this.requestFocus('ImageGridFocus_'); 266 }) 267 .geometryTransition(this.geometryTransitionString) 268 // @ts-ignore 269 .transition(TransitionEffect.asymmetric( 270 // @ts-ignore 271 TransitionEffect.scale({ x: AppStorage.Get('geometryScale'), y: AppStorage.Get('geometryScale') }), 272 // @ts-ignore 273 TransitionEffect.opacity(0.99))) 274 275 if (this.geometryTransitionBrowserId === '' || !this.isTapStatusChange) { 276 this.buildIcon(); 277 } 278 } 279 280 @Builder 281 buildIcon() { 282 if (this.item.mediaType == UserFileManagerAccess.MEDIA_TYPE_VIDEO || this.isRecycle) { 283 Row() { 284 // 缩略图左下角视频时长 285 if (this.item.mediaType == UserFileManagerAccess.MEDIA_TYPE_VIDEO) { 286 Text(DateUtil.getFormattedDuration(this.item.duration)) 287 .fontSize($r('sys.float.ohos_id_text_size_caption')) 288 .fontFamily($r('app.string.id_text_font_family_regular')) 289 .fontColor($r('app.color.text_color_above_picture')) 290 .lineHeight(12) 291 .margin({ 292 left: $r('app.float.grid_item_text_margin_lr'), 293 bottom: $r('app.float.grid_item_text_margin_bottom') 294 }) 295 .key('VideoDurationOfIndex' + this.mPosition) 296 } 297 // 缩略图右下角距离删除天数 298 if (this.isRecycle && !this.isSelectedMode) { 299 Blank() 300 301 Text($r('app.plural.recycle_days', this.recycleDays, this.recycleDays)) 302 .fontSize($r('sys.float.ohos_id_text_size_caption')) 303 .fontFamily($r('app.string.id_text_font_family_regular')) 304 .fontColor(this.recycleDays <= Constants.RECYCLE_DAYS_WARN ? $r('sys.color.ohos_id_color_warning') : $r('app.color.text_color_above_picture')) 305 .lineHeight(12) 306 .margin({ 307 right: $r('app.float.grid_item_text_margin_lr'), 308 bottom: $r('app.float.grid_item_text_margin_bottom') 309 }) 310 } 311 } 312 .position({ x: '0%', y: '50%' }) 313 .height('50%') 314 .width('100%') 315 .alignItems(VerticalAlign.Bottom) 316 .linearGradient({ angle: 0, colors: 317 [[$r('app.color.album_cover_gradient_start_color'), 0], [$r('app.color.transparent'), 1.0]] }) 318 } 319 320 if (this.item.isFavor) { 321 Image($r('app.media.ic_favorite_overlay')) 322 .height($r('app.float.overlay_icon_size')) 323 .width($r('app.float.overlay_icon_size')) 324 .fillColor($r('sys.color.ohos_id_color_primary_dark')) 325 .objectFit(ImageFit.Contain) 326 .position({ x: '100%', y: '0%' }) 327 .markAnchor({ 328 x: $r('app.float.grid_item_favor_markAnchor_x'), 329 y: $r('app.float.grid_item_favor_markAnchor_y') 330 }) 331 .key('Favor_' + this.mPosition) 332 } 333 334 // 当三方拉起 picker 时, 只有多选模式下才显示蒙层 335 if (this.isSelected && this.isSelectedMode && (!this.isThird || this.isThirdMultiPick)) { 336 Column() 337 .key('MaskLayer_' + this.mPosition) 338 .height('100%') 339 .width('100%') 340 .backgroundColor($r('app.color.item_selection_bg_color')) 341 } 342 343 // 缩略图上方功能图标 344 if (this.isSelectedMode) { 345 Image($r('app.media.ic_photo_preview')) 346 .key('Previewer_' + this.mPosition) 347 .height($r('app.float.icon_size')) 348 .width($r('app.float.icon_size')) 349 .position({ x: '0%', y: '0%' }) 350 .markAnchor({ 351 x: $r('app.float.grid_item_preview_padding'), 352 y: $r('app.float.grid_item_preview_padding') 353 }) 354 .onClick(() => { 355 Log.info(TAG, 'onClick loadThumbnailUri' + this.imageThumbnail); 356 this.routeToPreviewPage(); 357 Log.info(TAG, 'expand.'); 358 }) 359 } 360 if (this.isSelectedMode && (!this.isThird || this.isThirdMultiPick)) { 361 Checkbox() 362 .key('Selector_' + this.mPosition) 363 .select(this.isSelected) 364 .margin(0) 365 .position({ x: '100%', y: '100%' }) 366 .markAnchor({ 367 x: $r('app.float.grid_item_checkbox_markAnchor'), 368 y: $r('app.float.grid_item_checkbox_markAnchor') 369 }) 370 .focusable(false) 371 .hitTestBehavior(HitTestMode.None) 372 } 373 } 374 375 @Builder 376 buildNormal() { 377 Stack({ alignContent: Alignment.Start }) { 378 // 缩略图 379 if (this.isLoadImageError) { 380 Image((this.item.mediaType == UserFileManagerAccess.MEDIA_TYPE_VIDEO) 381 ? $r('app.media.alt_video_placeholder') : $r('app.media.alt_placeholder')) 382 .aspectRatio(1) 383 .rotate({ x: 0, y: 0, z: 1, angle: 0 }) 384 .objectFit(ImageFit.Cover) 385 .autoResize(false) 386 .focusSetting(this.item.uri, this.handleKeyEvent.bind(this)) 387 .onAppear(() => { 388 Log.debug(TAG, `appear the default image!`); 389 }) 390 391 if (this.geometryTransitionBrowserId === '' || !this.isTapStatusChange) { 392 this.buildIcon(); 393 } 394 } else { 395 if (this.albumUri === UserFileManagerAccess.getInstance() 396 .getSystemAlbumUri(UserFileManagerAccess.TRASH_ALBUM_SUB_TYPE) 397 || this.pageName === Constants.PHOTO_TRANSITION_TIMELINE) { 398 this.buildImage(); 399 } else { 400 Stack() { 401 this.buildImage(); 402 } 403 .borderRadius(0) 404 .clip(true) 405 .geometryTransition(this.transitionId) 406 } 407 } 408 } 409 .key('Gesture_' + this.mPosition) 410 .height('100%') 411 .width('100%') 412 .bindContextMenu(this.RightClickMenuBuilder, ResponseType.RightClick) // 右键点击菜单,后续整改至新组件 413 .scale({ 414 x: this.pressAnimScale, 415 y: this.pressAnimScale 416 }) 417 .onTouch(event => { 418 Log.debug(TAG, `onTouch trigger: isSelectedMode: ${this.isSelectedMode}, 419 isEnteringPhoto: ${this.isEnteringPhoto}, ${JSON.stringify(event)}`); 420 if (this.isSelectedMode) { 421 return; 422 } 423 424 // Press animation 425 if (event.type == TouchType.Down) { 426 animateTo({ 427 duration: Constants.PRESS_ANIM_DURATION, 428 curve: Curve.Ease 429 }, () => { 430 this.pressAnimScale = Constants.PRESS_ANIM_SCALE; 431 }) 432 } 433 434 if ((event.type == TouchType.Up || event.type == TouchType.Cancel) && this.pressAnimScale != 1) { 435 animateTo({ 436 duration: Constants.PRESS_ANIM_DURATION, 437 curve: Curve.Ease 438 }, () => { 439 this.pressAnimScale = 1; 440 }) 441 } 442 }) 443 .gesture(GestureGroup(GestureMode.Exclusive, 444 TapGesture().onAction((event: GestureEvent) => { 445 let ret: Boolean = focusControl.requestFocus('ImageGridFocus_' + this.item.uri); 446 if (ret !== true) { 447 Log.error(TAG, `requestFocus${'ImageGridFocus_' + this.item.uri}, ret:${ret}`); 448 } 449 let msg = { 450 'From': BigDataConstants.BY_CLICK, 451 } 452 ReportToBigDataUtil.report(BigDataConstants.ENTER_PHOTO_BROWSER_WAY, msg); 453 this.openPhotoBrowser(); 454 }), 455 LongPressGesture().onAction((event: GestureEvent) => { 456 Log.info(TAG, `LongPressGesture ${event}`); 457 this.selectStateChange(); 458 this.pressAnimScale = 1; 459 }) 460 )) 461 } 462 463 private resetPressAnim(): void { 464 this.pressAnimScale = 1; 465 this.isEnteringPhoto = false; 466 } 467 468 private onShow(): void { 469 this.resetPressAnim(); 470 } 471 472 private generateSampleSize(imageWidth: number, imageHeight: number): number { 473 let width = ScreenManager.getInstance().getWinWidth(); 474 let height = ScreenManager.getInstance().getWinHeight(); 475 width = width == 0 ? ScreenManager.DEFAULT_WIDTH : width; 476 height = height == 0 ? ScreenManager.DEFAULT_HEIGHT : height; 477 let maxNumOfPixels = width * height; 478 let minSide = Math.min(width, height); 479 return ImageUtil.computeSampleSize(imageWidth, imageHeight, minSide, maxNumOfPixels); 480 } 481 482 private changeTextResToPlural(action: Action): Resource { 483 let textStr: Resource = action.textRes; 484 if (Action.RECOVER.equals(action)) { 485 textStr = this.isSelected 486 ? $r('app.plural.action_recover_count', this.selectedCount, this.selectedCount) 487 : $r('app.string.action_recover'); 488 } else if (Action.DELETE.equals(action)) { 489 textStr = this.isSelected 490 ? $r('app.plural.action_delete_count', this.selectedCount, this.selectedCount) 491 : $r('app.string.action_delete'); 492 } else if (Action.MOVE.equals(action)) { 493 textStr = this.isSelected 494 ? $r('app.plural.move_to_album_count', this.selectedCount, this.selectedCount) 495 : $r('app.string.move_to_album'); 496 } else if (Action.ADD.equals(action)) { 497 textStr = this.isSelected 498 ? $r('app.plural.add_to_album_count', this.selectedCount, this.selectedCount) 499 : $r('app.string.add_to_album'); 500 } 501 return textStr; 502 } 503 504 // 获取最近删除中,待回收照片倒计天数 505 private calculateRecycleDays(): void { 506 let currentTimeSeconds: number = new Date().getTime() / 1000; 507 let trashedDay = DateUtil.convertSecondsToDays(currentTimeSeconds - this.item.dateTrashed); 508 Log.debug(TAG, `currentSec=${currentTimeSeconds}, trashedSec=${this.item.dateTrashed}, trashedDay=${trashedDay}`); 509 if (trashedDay > Constants.RECYCLE_DAYS_MAX) { 510 this.recycleDays = 0; 511 } else if (trashedDay <= 0) { 512 this.recycleDays = Constants.RECYCLE_DAYS_MAX - 1; 513 } else { 514 this.recycleDays = parseInt((Constants.RECYCLE_DAYS_MAX - trashedDay) + ''); 515 } 516 } 517 518 private requestFocus(keyName: string): void { 519 if (AppStorage.Get('deviceType') == Constants.DEFAULT_DEVICE_TYPE) { 520 return; 521 } 522 let positionUri = AppStorage.Get('focusPosition'); 523 let isUpdate = AppStorage.Get('focusUpdate'); 524 if (isUpdate && positionUri === this.item.uri) { 525 let ret: Boolean = focusControl.requestFocus(keyName + this.item.uri); 526 if (ret !== true) { 527 Log.error(TAG, `requestFocus${keyName + this.item.uri}, ret:${ret}`); 528 } 529 AppStorage.SetOrCreate('focusUpdate', false); 530 } 531 } 532 533 private openPhotoBrowser(): void { 534 if (this.isSelectedMode) { 535 this.selectStateChange(); 536 } else { 537 Log.info(TAG, 'item onClick loadBmp'); 538 Log.info(TAG, 'onClick loadThumbnailUri' + this.imageThumbnail); 539 this.updateGeometryTapInfo(); 540 if (this.isThird) { 541 this.broadCast.emit(BroadCastConstants.JUMP_THIRD_PHOTO_BROWSER, 542 [this.pageName, this.item, this.geometryTapIndex, this.geometryTransitionString]); 543 } else { 544 this.broadCast.emit(BroadCastConstants.JUMP_PHOTO_BROWSER, 545 [this.pageName, this.item, this.geometryTapIndex, this.geometryTransitionString]); 546 } 547 this.isEnteringPhoto = true; 548 } 549 } 550 551 private handleKeyEvent(event: KeyEvent): void { 552 if (KeyType.Up == event.type) { 553 switch (event.keyCode) { 554 case MultimodalInputManager.KEY_CODE_KEYBOARD_ENTER: 555 let msg = { 556 'From': BigDataConstants.BY_KEYBOARD, 557 } 558 ReportToBigDataUtil.report(BigDataConstants.ENTER_PHOTO_BROWSER_WAY, msg); 559 this.openPhotoBrowser(); 560 break; 561 case MultimodalInputManager.KEY_CODE_KEYBOARD_ESC: 562 this.onMenuClicked && this.onMenuClicked(Action.BACK); 563 break; 564 default: 565 Log.info(TAG, `on key event Up, default`); 566 break; 567 } 568 } 569 } 570 571 private updateGeometryTapInfo(): void { 572 this.geometryTapIndex = this.getPosition(); 573 } 574 575 private getPosition(): number { 576 return this.dataSource.getDataIndex(this.item) + this.dataSource.getGroupCountBeforeItem(this.item); 577 } 578}