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