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 private maxFonSizeScale: number = 2; 182 183 /** 184 * Accessibility Strings 185 */ 186 @State accessibilityConnectedStr: string = '已连接'; 187 @State accessibilityAudioControlStr: string = '音视频投播'; 188 189 MenuStateChange() { 190 if (this.extensionProxy != null) { 191 this.extensionProxy.send({ 'isMenuShow': this.isMenuShow }); 192 } 193 } 194 195 build() { 196 Column() { 197 if (this.customPicker === undefined) { 198 this.buildDefaultPicker(false); 199 } else { 200 this.buildCustomPicker(); 201 } 202 }.size({width: '100%', height: '100%'}) 203 } 204 205 @Builder 206 buildIcon(item: AVCastPickerDeviceInfo, isSelected: boolean): void { 207 if (this.deviceInfoType === 'true') { 208 SymbolGlyph(!isSelected ? $r(item.deviceIconName) : $r(item.selectedIconName)) 209 .fontSize('24vp') 210 .fontColor((isSelected && this.configurationColorMode !== ConfigurationColorMode.COLOR_MODE_DARK) ? 211 [$r('sys.color.comp_background_emphasize')] : [$r('sys.color.icon_primary')]) 212 .renderingStrategy(2) 213 } else { 214 Image(!isSelected ? $r(item.deviceIconName) : $r(item.selectedIconName)) 215 .width(24) 216 .height(24) 217 .fillColor((isSelected && this.configurationColorMode !== ConfigurationColorMode.COLOR_MODE_DARK) ? 218 $r('sys.color.comp_background_emphasize') : $r('sys.color.icon_primary')) 219 } 220 } 221 222 @Builder 223 deviceMenu() { 224 Column() { 225 ForEach(this.deviceList, (item: AVCastPickerDeviceInfo, index) => { 226 Flex({ 227 direction: FlexDirection.Column, 228 justifyContent: FlexAlign.SpaceBetween, 229 alignItems: ItemAlign.End 230 }) { 231 Flex({ 232 direction: FlexDirection.Row, 233 justifyContent: FlexAlign.SpaceBetween, 234 alignItems: ItemAlign.Center 235 }) { 236 Row() { 237 this.buildIcon(item, false) 238 239 Text(item.deviceName) 240 .fontSize($r('sys.float.ohos_id_text_size_body2')) 241 .fontColor(item.isConnected ? 242 (this.configurationColorMode !== ConfigurationColorMode.COLOR_MODE_DARK ? 243 $r('sys.color.font_emphasize') : $r('sys.color.font_primary')) : 244 (this.configurationColorMode !== ConfigurationColorMode.COLOR_MODE_DARK ? 245 $r('sys.color.font_primary') : $r('sys.color.font_secondary'))) 246 .width(144) 247 .padding({ 248 left: 8, 249 top: 12, 250 right: 8, 251 bottom: 12 252 }) 253 .textOverflow({ overflow: TextOverflow.Ellipsis }) 254 .maxLines(2) 255 .wordBreak(WordBreak.BREAK_ALL) 256 .maxFontScale(this.maxFonSizeScale) 257 } 258 .justifyContent(FlexAlign.Start) 259 .alignItems(VerticalAlign.Center) 260 261 if (item.isConnected && item.selectedIconName !== null && item.selectedIconName !== undefined) { 262 Row() { 263 this.buildIcon(item, true) 264 } 265 .justifyContent(FlexAlign.Start) 266 .alignItems(VerticalAlign.Center) 267 .accessibilityLevel('yes') 268 .accessibilityText(this.accessibilityConnectedStr) 269 } 270 } 271 .constraintSize({ minHeight: 48 }) 272 .padding({ left: 12, right: 12 }) 273 .onTouch((event) => { 274 if (event.type === TouchType.Down) { 275 this.touchMenuItemIndex = index; 276 } else if (event.type === TouchType.Up) { 277 this.touchMenuItemIndex = -1; 278 } 279 }) 280 .backgroundColor(this.touchMenuItemIndex === index 281 ? $r('sys.color.interactive_click') : '#00FFFFFF') 282 .borderRadius(this.touchMenuItemIndex === index 283 ? $r('sys.float.corner_radius_level8') : 0) 284 285 if (index != this.deviceList.length - 1) { 286 Divider() 287 .height(1) 288 .width(172) 289 .color($r('sys.color.comp_divider')) 290 .padding({ right: 12 }) 291 } 292 } 293 .width('100%') 294 .onClick(() => { 295 if (this.extensionProxy != null && !item.isConnected) { 296 this.extensionProxy.send({ 'selectedDeviceInfo': item }) 297 } 298 }) 299 }) 300 } 301 .width(216) 302 } 303 304 @Builder 305 private buildDefaultPicker(isCustomPicker: boolean) { 306 UIExtensionComponent( 307 { 308 abilityName: 'UIExtAbility', 309 bundleName: 'com.hmos.mediacontroller', 310 parameters: { 311 'normalColor': this.normalColor, 312 'activeColor': this.activeColor, 313 'pickerColorMode': this.colorMode, 314 'avCastPickerStyle': this.pickerStyle, 315 'ability.want.params.uiExtensionType': 'sysPicker/mediaControl', 316 'isCustomPicker': isCustomPicker, 317 } 318 }) 319 .onRemoteReady((proxy: UIExtensionProxy) => { 320 console.info(TAG, 'onRemoteReady'); 321 this.extensionProxy = proxy; 322 }) 323 .onReceive((data) => { 324 if (JSON.stringify(data['deviceInfoType']) !== undefined) { 325 console.info(TAG, `deviceInfoType : ${JSON.stringify(data['deviceInfoType'])}`); 326 this.deviceInfoType = data['deviceInfoType'] as string; 327 } 328 329 if (JSON.stringify(data['pickerStyle']) !== undefined) { 330 console.info(TAG, `picker style : ${JSON.stringify(data['pickerStyle'])}`); 331 this.pickerStyleFromMediaController = data['pickerStyle'] as AVCastPickerStyle; 332 } 333 334 if (JSON.stringify(data['deviceList']) !== undefined) { 335 console.info(TAG, `picker device list : ${JSON.stringify(data['deviceList'])}`); 336 this.deviceList = JSON.parse(JSON.stringify(data['deviceList'])); 337 let hasOnlySpeakerAndEarpiece: boolean = this.deviceList.length === 2 && !this.hasExtDevice(this.deviceList); 338 let hasNoDevices: boolean = this.deviceList === null || this.deviceList.length === 0; 339 let isCalling: boolean = this.sessionType === 'voice_call' || this.sessionType === 'video_call'; 340 let isExtMenuScene = isCalling && (hasNoDevices || hasOnlySpeakerAndEarpiece); 341 let isPanelForMedia: boolean = !isCalling && 342 (this.pickerStyle === AVCastPickerStyle.STYLE_PANEL && 343 this.pickerStyleFromMediaController === AVCastPickerStyle.STYLE_PANEL); 344 if (isExtMenuScene || isPanelForMedia) { 345 this.isMenuShow = false; 346 this.touchMenuItemIndex = -1; 347 } 348 } 349 350 if (JSON.stringify(data['state']) !== undefined) { 351 console.info(TAG, `picker state change : ${JSON.stringify(data['state'])}`); 352 let isCalling: boolean = (this.sessionType === 'voice_call' || this.sessionType === 'video_call'); 353 let isPanelForMedia: boolean = !isCalling && 354 (this.pickerStyle === AVCastPickerStyle.STYLE_PANEL && 355 this.pickerStyleFromMediaController === AVCastPickerStyle.STYLE_PANEL); 356 if (this.onStateChange != null && isPanelForMedia) { 357 if (parseInt(JSON.stringify(data['state'])) === AVCastPickerState.STATE_APPEARING) { 358 this.onStateChange(AVCastPickerState.STATE_APPEARING); 359 } else { 360 this.onStateChange(AVCastPickerState.STATE_DISAPPEARING); 361 } 362 } 363 } 364 365 if (JSON.stringify(data['sessionType']) !== undefined) { 366 console.info(TAG, `session type : ${JSON.stringify(data['sessionType'])}`); 367 this.sessionType = data['sessionType'] as string; 368 } 369 370 if (JSON.stringify(data['isShowMenu']) !== undefined) { 371 console.info(TAG, `isShowMenu : ${JSON.stringify(data['isShowMenu'])}`); 372 this.isMenuShow = data['isShowMenu'] as boolean; 373 if (!this.isMenuShow) { 374 this.touchMenuItemIndex = -1; 375 } 376 } 377 378 if (JSON.stringify(data['configurationColorMode']) !== undefined) { 379 console.info(TAG, `configurationColorMode : ${JSON.stringify(data['configurationColorMode'])}`); 380 this.configurationColorMode = data['configurationColorMode'] as number; 381 } 382 383 if (JSON.stringify(data['accessConnected']) !== undefined) { 384 console.info(TAG, `accessConnected : ${JSON.stringify(data['accessConnected'])}`); 385 this.accessibilityConnectedStr = data['accessConnected'] as string; 386 } 387 388 if (JSON.stringify(data['accessAudioControl']) !== undefined) { 389 console.info(TAG, `accessAudioControl : ${JSON.stringify(data['accessAudioControl'])}`); 390 this.accessibilityAudioControlStr = data['accessAudioControl'] as string; 391 } 392 }) 393 .size({ width: '100%', height: '100%' }) 394 .bindMenu(this.isMenuShow, this.deviceMenu(), { 395 placement: Placement.TopRight, 396 onDisappear: () => { 397 this.isMenuShow = false; 398 this.touchMenuItemIndex = -1; 399 this.menuShowStateCallback(this.isMenuShow); 400 }, 401 onAppear: () => { 402 if (this.extensionProxy != null && this.pickerClickTime !== -1) { 403 this.extensionProxy.send({ 'timeCost': new Date().getTime() - this.pickerClickTime}); 404 this.pickerClickTime = -1; 405 } 406 this.menuShowStateCallback(this.isMenuShow); 407 } 408 }) 409 .onClick(() => { 410 let hasOnlySpeakerAndEarpiece: boolean = this.deviceList.length === 2 && !this.hasExtDevice(this.deviceList); 411 let hasNoDevices: boolean = this.deviceList === null || this.deviceList.length === 0; 412 let isCalling: boolean = this.sessionType === 'voice_call' || this.sessionType === 'video_call'; 413 let isExtMenuScene: boolean = isCalling && (hasNoDevices || hasOnlySpeakerAndEarpiece); 414 let isPanelForMedia: boolean = !isCalling && 415 (this.pickerStyle === AVCastPickerStyle.STYLE_PANEL && 416 this.pickerStyleFromMediaController === AVCastPickerStyle.STYLE_PANEL); 417 if (isExtMenuScene || isPanelForMedia) { 418 if (this.extensionProxy != null) { 419 this.extensionProxy.send({'clickEvent': true}); 420 } 421 this.isMenuShow = false; 422 this.touchMenuItemIndex = -1; 423 } else { 424 this.isMenuShow = !this.isMenuShow; 425 if (this.isMenuShow) { 426 this.pickerClickTime = new Date().getTime(); 427 } else { 428 this.touchMenuItemIndex = -1; 429 } 430 } 431 }) 432 .accessibilityLevel('yes') 433 .accessibilityText(this.accessibilityAudioControlStr) 434 } 435 436 private hasExtDevice(allDevice: Array<AVCastPickerDeviceInfo>): boolean { 437 for (let i = 0; i < allDevice.length; i++) { 438 if (allDevice[i].deviceType !== 1 && // 1 is audio.DeviceType.EARPIECE 439 allDevice[i].deviceType !== 2) { // 2 is audio.DeviceType.SPEAKER 440 return true; 441 } 442 } 443 return false; 444 } 445 446 private menuShowStateCallback(isMenuShow: boolean): void { 447 if (this.onStateChange != null && 448 (this.pickerStyle === AVCastPickerStyle.STYLE_MENU || 449 this.pickerStyleFromMediaController === AVCastPickerStyle.STYLE_MENU)) { 450 let menuShowState: AVCastPickerState = isMenuShow ? 451 AVCastPickerState.STATE_APPEARING : AVCastPickerState.STATE_DISAPPEARING; 452 this.onStateChange(menuShowState); 453 } 454 } 455 456 @Builder 457 private buildCustomPicker() { 458 Stack({ alignContent: Alignment.Center}) { 459 Column() { 460 this.customPicker(); 461 } 462 .alignItems(HorizontalAlign.Center) 463 .justifyContent(FlexAlign.Center) 464 .size({ width: '100%', height: '100%' }) 465 .zIndex(0) 466 467 Column() { 468 this.buildDefaultPicker(true); 469 } 470 .alignItems(HorizontalAlign.Center) 471 .justifyContent(FlexAlign.Center) 472 .size({ width: '100%', height: '100%' }) 473 .zIndex(1) 474 } 475 .size({ width: '100%', height: '100%' }) 476 } 477} 478