• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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}