1/* 2 * Copyright (c) 2023-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 16const TAG = 'avcastpicker_component '; 17const castPlusAudioType = 8; 18const MAX_PICKER_LIMIT = 20; 19 20/** 21 * Definition of av cast picker state. 22 */ 23export enum AVCastPickerState { 24 /** 25 * The picker starts showing. 26 */ 27 STATE_APPEARING, 28 29 /** 30 * The picker finishes presenting. 31 */ 32 STATE_DISAPPEARING 33} 34 35/** 36 * Definition of av cast picker state. 37 */ 38export enum AVCastPickerStyle { 39 /** 40 * The picker shows in a panel style. 41 */ 42 STYLE_PANEL, 43 44 /** 45 * The picker shows in a menu style. 46 */ 47 STYLE_MENU 48} 49 50enum DeviceSource { 51 /** 52 * local device 53 */ 54 LOCAL, 55 56 /** 57 * cast device 58 */ 59 CAST 60} 61 62enum ConfigurationColorMode { 63 /** 64 * the color mode is not set. 65 */ 66 COLOR_MODE_NOT_SET = -1, 67 68 /** 69 * Dark mode. 70 */ 71 COLOR_MODE_DARK = 0, 72 73 /** 74 * Light mode. 75 */ 76 COLOR_MODE_LIGHT = 1 77} 78 79enum AVCastPickerColorMode { 80 /** 81 * the color mode of picker is not set. 82 */ 83 AUTO = 0, 84 85 /** 86 * Dark mode of picker. 87 */ 88 DARK = 1, 89 90 /** 91 * Light mode of picker. 92 */ 93 LIGHT = 2 94} 95 96interface HighQualityParams { 97 iconLeft: string, 98 iconRight: string, 99} 100 101/** 102 * menuItem device info 103 */ 104export interface AVCastPickerDeviceInfo { 105 deviceId: number | String, 106 deviceType: number, 107 deviceName: string, 108 deviceIconName: string, 109 isConnected: boolean, 110 selectedIconName: string, 111 deviceSource: DeviceSource, 112 networkId: string, 113 supportedProtocols?: number, 114 highQualityParams?: HighQualityParams, 115 fromCall?: boolean, 116 deviceSubName?: string, 117} 118 119@Component 120export struct AVCastPicker { 121 /** 122 * Assigns the color of picker component at normal state. 123 */ 124 @State normalColor: Color | number | string | undefined = undefined; 125 126 /** 127 * Assigns the color of picker component at active state. 128 */ 129 @State activeColor: Color | number | string | undefined = undefined; 130 131 /** 132 * Definition of color mode of picker. 133 */ 134 @State colorMode: AVCastPickerColorMode = AVCastPickerColorMode.AUTO; 135 136 /** 137 * The device that is displayed in the menu. 138 */ 139 @State deviceList: Array<AVCastPickerDeviceInfo> = []; 140 141 /** 142 * The scale of font size. 143 */ 144 @State fontSizeScale: number = 1; 145 146 /** 147 * Session type transferred by the application. 148 */ 149 @State sessionType: string = 'audio'; 150 151 /** 152 * Display form of application transfer. 153 */ 154 @State pickerStyle: AVCastPickerStyle = AVCastPickerStyle.STYLE_PANEL; 155 156 /** 157 * Display form mediaController. 158 */ 159 @State pickerStyleFromMediaController: AVCastPickerStyle = AVCastPickerStyle.STYLE_PANEL; 160 161 /** 162 * Whether to display the menu. 163 */ 164 @State@Watch('MenuStateChange') isMenuShow: boolean = false; 165 166 /** 167 * Touch item index. 168 */ 169 @State touchMenuItemIndex: number = -1; 170 171 /** 172 * Picker state change callback. 173 */ 174 private onStateChange?: (state: AVCastPickerState) => void; 175 176 /** 177 * UIExtensionProxy. 178 */ 179 private extensionProxy: UIExtensionProxy | null = null; 180 181 private pickerClickTime: number = -1; 182 183 /** 184 * Custom builder from application. 185 */ 186 @BuilderParam customPicker: (() => void) 187 188 /** 189 * Configuration color mode. 190 */ 191 @State configurationColorMode: number = ConfigurationColorMode.COLOR_MODE_NOT_SET; 192 193 @State deviceInfoType: string = ''; 194 195 /** 196 * Max Font and graphic magnification. 197 */ 198 @State maxFontSizeScale: number = 1; 199 200 /** 201 * Accessibility Strings 202 */ 203 @State accessibilityConnectedStr: string = '已连接'; 204 @State accessibilityAudioControlStr: string = '音视频投播'; 205 206 @State isPc: boolean = false; 207 @State isRTL: boolean = false; 208 @State restartUECMessage: number = 1; 209 private needToRestart: boolean = false; 210 @State isShowLoadingProgress: boolean = false; 211 private pickerCountOnCreation: number = 0; 212 @State isDisabledByPickerLimit: boolean = false; 213 214 private static currentPickerCount: number = 0; 215 216 aboutToAppear(): void { 217 AVCastPicker.currentPickerCount += 1; 218 this.pickerCountOnCreation = AVCastPicker.currentPickerCount; 219 if (this.pickerCountOnCreation > MAX_PICKER_LIMIT) { 220 console.info(TAG, 'disable picker'); 221 this.isDisabledByPickerLimit = true; 222 } 223 } 224 225 aboutToDisappear(): void { 226 AVCastPicker.currentPickerCount -= 1; 227 } 228 229 MenuStateChange() { 230 if (this.extensionProxy != null) { 231 this.extensionProxy.send({ 'isMenuShow': this.isMenuShow }); 232 } 233 } 234 235 private showHighQuality(item: AVCastPickerDeviceInfo): boolean { 236 if (item.supportedProtocols === undefined) { 237 return false; 238 } 239 return (item.supportedProtocols & castPlusAudioType) !== 0; 240 } 241 242 build() { 243 Column() { 244 if (this.isDisabledByPickerLimit) { 245 this.buildDisabledPicker(); 246 } else if (this.customPicker === undefined) { 247 this.buildDefaultPicker(false); 248 } else { 249 this.buildCustomPicker(); 250 } 251 }.size({width: '100%', height: '100%'}) 252 } 253 254 @Builder 255 iconBuilder(item: AVCastPickerDeviceInfo, isSelected: boolean): void { 256 if (this.deviceInfoType === 'true') { 257 SymbolGlyph(!isSelected ? $r(item.deviceIconName) : $r(item.selectedIconName)) 258 .fontSize('24vp') 259 .fontColor((isSelected && this.configurationColorMode !== ConfigurationColorMode.COLOR_MODE_DARK) ? 260 [$r('sys.color.comp_background_emphasize')] : [$r('sys.color.icon_primary')]) 261 .renderingStrategy(2) 262 } else { 263 Image(!isSelected ? $r(item.deviceIconName) : $r(item.selectedIconName)) 264 .width(24) 265 .height(24) 266 .fillColor((isSelected && this.configurationColorMode !== ConfigurationColorMode.COLOR_MODE_DARK) ? 267 $r('sys.color.comp_background_emphasize') : $r('sys.color.icon_primary')) 268 } 269 } 270 271 272 @Builder 273 textBuilder(item: AVCastPickerDeviceInfo) { 274 Text(item.deviceName) 275 .fontSize($r('sys.float.ohos_id_text_size_body2')) 276 .fontColor(item.isConnected ? 277 (this.configurationColorMode !== ConfigurationColorMode.COLOR_MODE_DARK ? 278 $r('sys.color.font_emphasize') : $r('sys.color.font_primary')) : 279 (this.configurationColorMode !== ConfigurationColorMode.COLOR_MODE_DARK ? 280 $r('sys.color.font_primary') : $r('sys.color.font_secondary'))) 281 .textOverflow({ overflow: TextOverflow.Ellipsis }) 282 .maxLines(item.fromCall ? 1 : 2); 283 .wordBreak(WordBreak.BREAK_WORD) 284 .maxFontScale(this.maxFontSizeScale) 285 .direction(this.isRTL ? Direction.Rtl : Direction.Ltr) 286 } 287 288 @Builder 289 subTextBuilder(item: AVCastPickerDeviceInfo) { 290 Row() { 291 Text(`${item.deviceSubName}...`) 292 .fontSize($r('sys.float.ohos_id_text_size_body2')) 293 .fontColor(item.isConnected ? 294 (this.configurationColorMode !== ConfigurationColorMode.COLOR_MODE_DARK ? 295 $r('sys.color.font_emphasize') : $r('sys.color.font_primary')) : 296 (this.configurationColorMode !== ConfigurationColorMode.COLOR_MODE_DARK ? 297 $r('sys.color.font_primary') : $r('sys.color.font_secondary'))) 298 .textOverflow({ overflow: TextOverflow.Ellipsis }) 299 .maxLines(1) 300 .maxFontScale(this.maxFontSizeScale) 301 .direction(this.isRTL ? Direction.Rtl : Direction.Ltr) 302 } 303 .width('100%') 304 } 305 306 @Builder 307 highQualityIconBuilder(params: HighQualityParams) { 308 Row() { 309 Text(params.iconLeft) 310 .highQualityStyles(this.maxFontSizeScale) 311 Text(params.iconRight) 312 .highQualityStyles(this.maxFontSizeScale) 313 .margin({left: 2 * (Math.min(this.maxFontSizeScale, this.fontSizeScale))}) 314 } 315 .direction(Direction.Ltr) 316 } 317 318 @Builder 319 deviceMenu() { 320 Column() { 321 ForEach(this.deviceList, (item: AVCastPickerDeviceInfo, index) => { 322 Flex({ 323 direction: FlexDirection.Column, 324 justifyContent: FlexAlign.SpaceBetween, 325 alignItems: ItemAlign.End 326 }) { 327 Flex({ 328 direction: FlexDirection.Row, 329 justifyContent: FlexAlign.SpaceBetween, 330 alignItems: ItemAlign.Center 331 }) { 332 Row() { 333 this.iconBuilder(item, false) 334 Column() { 335 Flex({direction: FlexDirection.Row, justifyContent: FlexAlign.Start}) { 336 this.textBuilder(item) 337 if (item.highQualityParams !== undefined && this.showHighQuality(item)) { 338 Flex() { 339 this.highQualityIconBuilder(item.highQualityParams) 340 } 341 .borderRadius(3) 342 .border({ 343 width: 0.5 * (Math.min(this.maxFontSizeScale, this.fontSizeScale)), 344 color: $r('sys.color.font_secondary') 345 }) 346 .padding({top: 1.5, right: 4, bottom: 1.5, left: 4}) 347 .margin({top: 2}) 348 .width('auto') 349 } 350 } 351 .width('100%') 352 353 if (item.fromCall) { 354 this.subTextBuilder(item) 355 } 356 } 357 .width(this.isPc ? 254 : 144) 358 .padding({ 359 left: 8, 360 top: this.isPc ? 11 : (this.showHighQuality(item) ? 7 : 17), 361 right: 8, 362 bottom: this.isPc ? 11 : (this.showHighQuality(item) ? 7 : 17), 363 }) 364 } 365 .alignItems(VerticalAlign.Center) 366 367 if (item.isConnected && item.selectedIconName !== null && item.selectedIconName !== undefined) { 368 Row() { 369 this.iconBuilder(item, true) 370 } 371 .alignItems(VerticalAlign.Center) 372 .accessibilityLevel('yes') 373 .accessibilityText(this.accessibilityConnectedStr) 374 } 375 } 376 .constraintSize({ minHeight: this.isPc ? 40 : 48 }) 377 .padding({ left: 12, right: 12 }) 378 .onTouch((event) => { 379 if (event.type === TouchType.Down) { 380 this.touchMenuItemIndex = index; 381 } else if (event.type === TouchType.Up) { 382 this.touchMenuItemIndex = -1; 383 } 384 }) 385 .backgroundColor(this.touchMenuItemIndex === index ? 386 $r('sys.color.interactive_click') : '#00FFFFFF') 387 .borderRadius(this.touchMenuItemIndex === index ? 388 (this.isPc ? $r('sys.float.corner_radius_level2') : $r('sys.float.corner_radius_level8')) : 0) 389 390 if (!this.isPc && (index != this.deviceList.length - 1)) { 391 Divider() 392 .height(1) 393 .color($r('sys.color.comp_divider')) 394 .padding({ right: (this.isRTL ? 44 : 12), left: (this.isRTL ? 12 : 44) }) 395 } else if (this.isPc && (index != this.deviceList.length - 1)) { 396 Row() 397 .width('100%') 398 .height(2) 399 } 400 } 401 .width('100%') 402 .onClick(() => { 403 if (this.extensionProxy != null && !item.isConnected) { 404 this.extensionProxy.send({ 'selectedDeviceInfo': item }) 405 } 406 }) 407 }) 408 } 409 .width(this.isPc ? 326 : 216) 410 .borderRadius(this.isPc ? 8 : 20) 411 } 412 413 @Builder 414 private buildDisabledPicker() { 415 Column(); 416 } 417 418 @Builder 419 private buildDefaultPicker(isCustomPicker: boolean) { 420 Button() { 421 Column() { 422 UIExtensionComponent( 423 { 424 abilityName: 'UIExtAbility', 425 bundleName: 'com.hmos.mediacontroller', 426 parameters: { 427 'normalColor': this.normalColor, 428 'activeColor': this.activeColor, 429 'pickerColorMode': this.colorMode, 430 'avCastPickerStyle': this.pickerStyle, 431 'ability.want.params.uiExtensionType': 'sysPicker/mediaControl', 432 'isCustomPicker': isCustomPicker, 433 'message': this.restartUECMessage, 434 'currentPickerCount': this.pickerCountOnCreation, 435 } 436 }) 437 .onRemoteReady((proxy: UIExtensionProxy) => { 438 console.info(TAG, 'onRemoteReady'); 439 this.extensionProxy = proxy; 440 }) 441 .onReceive((data) => { 442 if (data['deviceInfoType'] !== undefined) { 443 console.info(TAG, `deviceInfoType : ${JSON.stringify(data['deviceInfoType'])}`); 444 this.deviceInfoType = data['deviceInfoType'] as string; 445 } 446 447 if (data['pickerStyle'] !== undefined) { 448 console.info(TAG, `picker style : ${JSON.stringify(data['pickerStyle'])}`); 449 this.pickerStyleFromMediaController = data['pickerStyle'] as AVCastPickerStyle; 450 } 451 452 if (data['deviceList'] !== undefined) { 453 console.info(TAG, `picker device list : ${JSON.stringify(data['deviceList'])}`); 454 this.deviceList = JSON.parse(JSON.stringify(data['deviceList'])); 455 let hasOnlySpeakerAndEarpiece: boolean = this.deviceList.length === 2 && 456 !this.hasExtDevice(this.deviceList); 457 let hasNoDevices: boolean = this.deviceList === null || this.deviceList.length === 0; 458 let isCalling: boolean = this.sessionType === 'voice_call' || this.sessionType === 'video_call'; 459 let isExtMenuScene = isCalling && (hasNoDevices || hasOnlySpeakerAndEarpiece); 460 let isPanelForMedia: boolean = !isCalling && 461 (this.pickerStyle === AVCastPickerStyle.STYLE_PANEL && 462 this.pickerStyleFromMediaController === AVCastPickerStyle.STYLE_PANEL); 463 if (isExtMenuScene || isPanelForMedia) { 464 this.isMenuShow = false; 465 this.touchMenuItemIndex = -1; 466 } 467 } 468 469 if (data['fontSizeScale'] !== undefined) { 470 console.info(TAG, `font size scale : ${JSON.stringify(data['fontSizeScale'])}`); 471 this.fontSizeScale = data['fontSizeScale'] as number; 472 } 473 474 if (data['state'] !== undefined) { 475 console.info(TAG, `picker state change : ${JSON.stringify(data['state'])}`); 476 let isCalling: boolean = (this.sessionType === 'voice_call' || this.sessionType === 'video_call'); 477 let isPanelForMedia: boolean = !isCalling && 478 (this.pickerStyle === AVCastPickerStyle.STYLE_PANEL && 479 this.pickerStyleFromMediaController === AVCastPickerStyle.STYLE_PANEL); 480 if (this.onStateChange != null && isPanelForMedia) { 481 if (parseInt(JSON.stringify(data['state'])) === AVCastPickerState.STATE_APPEARING) { 482 this.onStateChange(AVCastPickerState.STATE_APPEARING); 483 } else { 484 this.onStateChange(AVCastPickerState.STATE_DISAPPEARING); 485 } 486 } 487 } 488 489 if (data['sessionType'] !== undefined) { 490 console.info(TAG, `session type : ${JSON.stringify(data['sessionType'])}`); 491 this.sessionType = data['sessionType'] as string; 492 } 493 494 if (data['isShowMenu'] !== undefined) { 495 console.info(TAG, `isShowMenu : ${JSON.stringify(data['isShowMenu'])}`); 496 this.isMenuShow = data['isShowMenu'] as boolean; 497 if (!this.isMenuShow) { 498 this.touchMenuItemIndex = -1; 499 } 500 } 501 502 if (data['configurationColorMode'] !== undefined) { 503 console.info(TAG, `configurationColorMode : ${JSON.stringify(data['configurationColorMode'])}`); 504 this.configurationColorMode = data['configurationColorMode'] as number; 505 } 506 507 if (data['accessConnected'] !== undefined) { 508 console.info(TAG, `accessConnected : ${JSON.stringify(data['accessConnected'])}`); 509 this.accessibilityConnectedStr = data['accessConnected'] as string; 510 } 511 512 if (data['accessAudioControl'] !== undefined) { 513 console.info(TAG, `accessAudioControl : ${JSON.stringify(data['accessAudioControl'])}`); 514 this.accessibilityAudioControlStr = data['accessAudioControl'] as string; 515 } 516 517 if (data['isPc'] !== undefined) { 518 console.info(TAG, `isPc : ${JSON.stringify(data['isPc'])}`); 519 this.isPc = data['isPc'] as boolean; 520 } 521 522 if (data['isRTL'] !== undefined) { 523 console.info(TAG, `isRTL : ${JSON.stringify(data['isRTL'])}`); 524 this.isRTL = data['isRTL'] as boolean; 525 } 526 527 if (data['maxFontSizeScale'] !== undefined) { 528 console.info(TAG, `maxFontSizeScale : ${JSON.stringify(data['maxFontSizeScale'])}`); 529 this.maxFontSizeScale = data['maxFontSizeScale'] as number; 530 } 531 532 if (data['isShowLoadingProgress'] !== undefined) { 533 console.info(TAG, `isShowLoadingProgress : ${JSON.stringify(data['isShowLoadingProgress'])}`); 534 this.isShowLoadingProgress = data['isShowLoadingProgress'] as boolean; 535 } 536 }) 537 .size({ width: '100%', height: '100%' }) 538 .bindMenu(this.isMenuShow, this.deviceMenu(), { 539 placement: Placement.BottomRight, 540 onDisappear: () => { 541 this.isMenuShow = false; 542 this.touchMenuItemIndex = -1; 543 this.menuShowStateCallback(this.isMenuShow); 544 }, 545 onAppear: () => { 546 if (this.extensionProxy != null && this.pickerClickTime !== -1) { 547 this.extensionProxy.send({ 'timeCost': new Date().getTime() - this.pickerClickTime}); 548 this.pickerClickTime = -1; 549 } 550 this.menuShowStateCallback(this.isMenuShow); 551 } 552 }) 553 .onRelease((releaseCode) => { 554 console.error(TAG, `onRelease code ${releaseCode}`); 555 if (releaseCode === 1) { 556 this.needToRestart = true; 557 } 558 }) 559 .onError(() => { 560 console.error(TAG, 'onError ready to restart'); 561 this.needToRestart = true; 562 }) 563 } 564 .accessibilityLevel('no-hide-descendants') 565 .size({ width: '100%', height: '100%' }) 566 } 567 .hoverEffect(HoverEffect.None) 568 .backgroundColor('#00000000') 569 .stateEffect(false) 570 .size({ width: '100%', height: '100%' }) 571 .accessibilityLevel('yes') 572 .accessibilityText(this.accessibilityAudioControlStr) 573 .onClick(() => { 574 if (this.needToRestart) { 575 this.needToRestart = false; 576 this.restartUECMessage += 1; 577 return; 578 } 579 let hasOnlySpeakerAndEarpiece: boolean = this.deviceList.length === 2 && !this.hasExtDevice(this.deviceList); 580 let hasNoDevices: boolean = this.deviceList === null || this.deviceList.length === 0; 581 let isCalling: boolean = this.sessionType === 'voice_call' || this.sessionType === 'video_call'; 582 let isExtMenuScene: boolean = isCalling && (hasNoDevices || hasOnlySpeakerAndEarpiece); 583 let isPanelForMedia: boolean = !isCalling && 584 (this.pickerStyle === AVCastPickerStyle.STYLE_PANEL && 585 this.pickerStyleFromMediaController === AVCastPickerStyle.STYLE_PANEL); 586 if (isExtMenuScene || isPanelForMedia) { 587 this.isMenuShow = false; 588 this.touchMenuItemIndex = -1; 589 if (this.extensionProxy != null) { 590 this.extensionProxy.send({'clickEvent': true}); 591 } 592 } else { 593 this.isMenuShow = !this.isMenuShow; 594 if (this.isMenuShow) { 595 this.pickerClickTime = new Date().getTime(); 596 } else { 597 this.touchMenuItemIndex = -1; 598 } 599 } 600 }) 601 } 602 603 private hasExtDevice(allDevice: Array<AVCastPickerDeviceInfo>): boolean { 604 for (let i = 0; i < allDevice.length; i++) { 605 let isEarpieceOrSpeaker: boolean = (allDevice[i].deviceType === 1 || allDevice[i].deviceType === 2) && 606 allDevice[i].networkId === 'LocalDevice'; 607 if (!isEarpieceOrSpeaker) { 608 return true; 609 } 610 } 611 return false; 612 } 613 614 private menuShowStateCallback(isMenuShow: boolean): void { 615 if (this.onStateChange != null && 616 (this.pickerStyle === AVCastPickerStyle.STYLE_MENU || 617 this.pickerStyleFromMediaController === AVCastPickerStyle.STYLE_MENU)) { 618 let menuShowState: AVCastPickerState = isMenuShow ? 619 AVCastPickerState.STATE_APPEARING : AVCastPickerState.STATE_DISAPPEARING; 620 this.onStateChange(menuShowState); 621 } 622 } 623 624 @Builder 625 private buildCustomPicker() { 626 Stack({ alignContent: Alignment.Center}) { 627 Column() { 628 if (this.isShowLoadingProgress) { 629 LoadingProgress() 630 .color($r('sys.color.icon_secondary')) 631 .width('20vp') 632 .height('20vp') 633 } else { 634 this.customPicker(); 635 } 636 } 637 .alignItems(HorizontalAlign.Center) 638 .justifyContent(FlexAlign.Center) 639 .size({ width: '100%', height: '100%' }) 640 .zIndex(0) 641 642 Column() { 643 this.buildDefaultPicker(true); 644 } 645 .alignItems(HorizontalAlign.Center) 646 .justifyContent(FlexAlign.Center) 647 .size({ width: '100%', height: '100%' }) 648 .zIndex(1) 649 } 650 .size({ width: '100%', height: '100%' }) 651 } 652} 653 654@Extend(Text) function highQualityStyles(maxScale: number) { 655 .fontSize(7) 656 .fontWeight(FontWeight.Medium) 657 .fontColor($r('sys.color.font_secondary')) 658 .maxFontScale(maxScale) 659}