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