1.4. 面試錄音

1.4.1. 頁面結構

目的:準備頁面的組件結構,搭建頁面基本效果

pages/Audio/AudioPage.ets

import { promptAction } from '@kit.ArkUI'
import { Permissions } from '@kit.AbilityKit'
import { permission } from '../../commons/utils/Permission'
import { navPathStack } from '../Index'
import { AudioView } from './components/AudioView'

@ComponentV2
struct AudioPage {
  permissions: Permissions[] = ['ohos.permission.MICROPHONE']
  confirmConfig: promptAction.ShowDialogOptions = {
    title: "温馨提示",
    message: "未授權使用麥克風將無法使用該面試錄音功能,是否前往設置進行授權?",
    buttons: [
      { text: '離開', color: $r('app.color.common_gray_01') },
      { text: '去授權', color: $r('app.color.black') }
    ]
  }

  async getPermission() {
    try {
      // 第一請求授權
      const isOk = await permission.requestPermissions(this.permissions)
      if (isOk) {
        return
      }
      // 未授權彈窗提示
      const confirm = await promptAction.showDialog(this.confirmConfig)
      if (confirm.index === 1) {
        // 第二次請求權限
        const isOk2 = await permission.openPermissionSetting(this.permissions)
        if (isOk2) {
          return
        }
      }
      navPathStack.pop()
    } catch (e) {
      promptAction.showToast({ message: '未授權' })
      navPathStack.pop()
    }
  }

  aboutToAppear() {
    this.getPermission()
  }

  build() {
    //必須用NavDestination包裹
    NavDestination() {
      Column() {
        AudioView()
      }
    }
    .hideTitleBar(true)
  }
}

// 跳轉頁面入口函數
@Builder
export function AudioBuilder() {
  AudioPage()
}

Audio/components/AudioView.ets 錄音視圖

import { HcNavBar } from "../../../commons/components/HcNavBar"
import { InterviewAudioItem } from "../../../commons/utils/AudioDB"
import { AudioItemComp } from "./AudioItemComp"
import { AudioRecordComp } from "./AudioRecordComp"

@ComponentV2
  export struct AudioView {
    @Local list: InterviewAudioItem[] = [{} as InterviewAudioItem, {} as InterviewAudioItem ]

    build() {
      Column() {
        HcNavBar({ title: '面試錄音', showRightIcon: false })
        Column() {
          List() {
            ForEach(this.list, (item: InterviewAudioItem) => {
              ListItem() {
                AudioItemComp({
                  item: {
                    id: 1,
                    name: '2024年10月01日_10點10分10秒',
                    path: '/data/el/xxx',
                    user_id: '100',
                    duration: 10000,
                    size: 10000,
                    create_time: 10000
                  }
                })
              }
            })
          }
          .width('100%')
            .height('100%')
        }
        .width('100%')
          .layoutWeight(1)

        AudioRecordComp()
      }
      .width('100%')
        .height('100%')
    }
  }

Audio/components/AudioItemComp.ets 單條錄音數據數組

import { InterviewAudioItem } from "../../../commons/utils/AudioDB"

@ComponentV2
  export struct AudioItemComp {
    @Param item: InterviewAudioItem = {} as InterviewAudioItem

    build() {
      Row({ space: 15 }) {
        Image($r('app.media.ic_mine_audio'))
          .width(50)
          .aspectRatio(1)
        Column({ space: 10 }) {
          Text(this.item.name)
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
          Row({ space: 20 }) {
            Text(`時長:${(this.item.duration / 1000).toFixed(0)} 秒`)
              .fontSize(14)
              .fontColor($r('app.color.common_gray_03'))
            Text(`大小:${(this.item.size / 1000).toFixed(0)} KB`)
              .fontSize(14)
              .fontColor($r('app.color.common_gray_03'))
          }
          .width('100%')
        }
        .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          .alignSelf(ItemAlign.Start)
      }
      .padding(15)
        .height(80)
        .width('100%')
    }
  }

Audio/components/AudioRecordComp.ets 錄音組件

import { media } from '@kit.MediaKit'
import { fileIo } from '@kit.CoreFileKit'
import { AppStorageV2 } from '@kit.ArkUI'
import { AreaHeight } from '../../../models/AreaHeight'

@ComponentV2
  export struct AudioRecordComp {
    areaHeight: AreaHeight = AppStorageV2.connect(AreaHeight, () => new AreaHeight(0, 0))!
    avRecorder?: media.AVRecorder
    fd?: number
    filePath?: string
    timer?: number
    @Local maxAmplitude: number = 0

    async startRecord() {
      // 1. 準備一個文件接收錄音
      const ctx = getContext(this)
      const filePath = ctx.filesDir + '/' + Date.now() + '.m4a'
      this.filePath = filePath
      const file = fileIo.openSync(filePath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE)
      this.fd = file.fd
      // 2. 準備路由配置對象
      const config: media.AVRecorderConfig = {
        audioSourceType: media.AudioSourceType.AUDIO_SOURCE_TYPE_MIC,
        profile: {
          audioBitrate: 100000, // 音頻比特率
          audioChannels: 1, // 音頻聲道數
          audioCodec: media.CodecMimeType.AUDIO_AAC, // 音頻編碼格式,當前只支持aac
          audioSampleRate: 48000, // 音頻採樣率
          fileFormat: media.ContainerFormatType.CFT_MPEG_4A, // 封裝格式,當前只支持m4a
        },
        url: `fd://${file.fd}`
      }
      // 3. 開始錄製
      const avRecorder = await media.createAVRecorder()
      await avRecorder.prepare(config)
      await avRecorder.start()
      this.avRecorder = avRecorder
      // 4. 每100ms獲取一下聲音振幅
      this.timer = setInterval(async () => {
        this.maxAmplitude = await avRecorder.getAudioCapturerMaxAmplitude()
      }, 100)
    }

    async stopRecord() {
      if (this.avRecorder) {
        clearInterval(this.timer)
        await this.avRecorder.stop()
        await this.avRecorder.release()
        fileIo.closeSync(this.fd)
        this.maxAmplitude = 0
      }
    }

    build() {
      Column() {
        AudioBoComp({ maxAmplitude: this.maxAmplitude })
        Row() {
          Image($r('sys.media.ohos_ic_public_voice'))
            .width(24)
            .aspectRatio(1)
            .fillColor($r('app.color.white'))
            .onClick(async () => {
              // TODO 開始和停止錄音
            })
        }
        .justifyContent(FlexAlign.Center)
          .height(50)
          .width(50)
          .borderRadius(25)
          .margin({ top: 20 })
          .backgroundColor($r('app.color.black'))
      }
      .width('100%')
        .height(240)
        .backgroundColor($r('app.color.common_gray_bg'))
        .padding({ bottom: this.areaHeight.bottomHeight, left: 80, right: 80, top: 20 })
    }
  }

@ComponentV2
  export struct AudioBoComp {
    @Param maxAmplitude: number = 0
    @Local per: number = 0
    @Monitor('maxAmplitude')
    onChange () {
      animateTo({ duration: 100 }, () => {
        if (this.maxAmplitude < 500) {
          this.per = 0
        } else if (this.maxAmplitude > 30000) {
          this.per = 1
        } else {
          this.per = this.maxAmplitude / 30000
        }
      })
    }

    build() {
      Row({ space: 5 }) {
        ForEach(Array.from({ length: 30 }), () => {
          Column()
            .layoutWeight(1)
            .height(this.per * 100 * Math.random())
            .backgroundColor($r('app.color.common_blue'))
      })
    }
    .width('100%')
    .height(100)
    .backgroundColor($r('app.color.common_gray_bg'))
  }
}

1.4.2. 添加錄音

目標:點擊錄音按鈕開啓錄音,再次點擊結束錄音,存儲錄音信息

落地代碼:

1)組件實現錄製狀態切換 views/AudioRecordComp.ets

@Local recording: boolean = false
@Local startTime: number = 0

Image($r('sys.media.ohos_ic_public_voice'))
  .width(24)
  .aspectRatio(1)
  .fillColor($r('app.color.white'))
  .onClick(async () => {
    if (this.recording) {
      await this.stopRecord()
      this.recording = false
      // TODO 記錄錄音
    } else {
      await this.startRecord()
      this.recording = true
    }
  })



Row() {
  ...
}
.backgroundColor(this.recording ? $r('app.color.common_main_color') : $r('app.color.black'))

2)組件暴露錄製結束事件

@Event onRecordEnd: (item: InterviewAudioItem) => void = () => {}

.onClick(async () => {
  if (this.recording) {
    await this.stopRecord()
    this.recording = false

    // TODO 記錄錄音
    const stat = fileIo.statSync(this.filePath)
    this.onRecordEnd({
      id: null,
      name: dayjs().format('YYYY年MM月DD日_HH時mm分ss秒'),
      path  : this.filePath || '',
      duration:  Date.now() - this.startTime,
      size: stat.size,
      user_id: auth.getUser().id,
      create_time: Date.now()
    })
  } else {
    await this.startRecord()
    this.recording = true
    this.startTime = Date.now()
  }
})

下載dayjs依賴

ohpm install dayjs

// 導包
import dayjs from 'dayjs'

3)父組件在錄製結束後,插入數據庫完成添加

async aboutToAppear() {
  await audioDB.initStore()
}

AudioRecordComp({
  onRecordEnd: async (item: InterviewAudioItem) => {
    await audioDB.insert(item)
    // TODO 更新列表
  }
})

1.4.3. 渲染列表

目標:完成錄音列表展示

1)獲取數據庫錄音數據

async getList() {
  const user = auth.getUser()
  const rows = await audioDB.query(user.id)
  this.list = rows
}

async aboutToAppear() {
  await audioDB.initStore()
  await this.getList()
}

2)渲染列表

ForEach(this.list, (item: InterviewAudioItem) => {
  ListItem() {
    AudioItemComp({
      item: item
    })
  }
})

1.4.4. 刪除錄音

目標:通過滑動操作完成錄音刪除

1)準備滑動刪除和編輯效果

@Builder
ListItemSwiperBuilder(item: InterviewAudioItem) {
  Row() {
    Text('編輯')
      .actionButton($r('app.color.common_blue'))
    Text('刪除')
      .actionButton('#FF0033')
  }
  .height('100%')
}

@Extend(Text)
function actionButton(color: ResourceColor) {
  .width(80)
    .aspectRatio(1)
    .backgroundColor(color)
    .textAlign(TextAlign.Center)
    .fontColor($r('app.color.white'))
}

ListItem() {
  AudioItemComp({
    item: item
  })
}
.swipeAction({
  end: this.ListItemSwiperBuilder(item)
})

2)實現刪除

Text('刪除')
  .actionButton('#FF0033')
  .onClick(async () => {
    await audioDB.delete(item.id!)
    this.getList()
  })

1.4.5. 編輯錄音

目標:實現彈窗對話框,修改錄音名稱

1)準備對話框

@CustomDialog
  export struct InputDialog {
    controller: CustomDialogController
    @Prop name: string = ''
    onSubmit: (name: string) => void = () => {
    }

    build() {
      Column({ space: 12 }) {
        Text('修改名字:')
          .height(40)
          .fontWeight(500)
        TextInput({ text: $$this.name })
        Row({ space: 120 }) {
          Text('取消')
            .fontWeight(500)
            .fontColor($r('app.color.common_gray_02'))
            .onClick(() => {
              this.controller.close()
            })
          Text('確認')
            .fontWeight(500)
            .fontColor($r('app.color.common_blue'))
            .onClick(() => {
              this.onSubmit(this.name)
            })
        }
        .height(40)
          .width('100%')
          .justifyContent(FlexAlign.Center)
      }
      .alignItems(HorizontalAlign.Start)
        .padding(16)
        .borderRadius(12)
        .width('80%')
        .backgroundColor($r('app.color.white'))
    }
  }

2)彈出對話框

@Local currentItem: InterviewAudioItem = {} as InterviewAudioItem

dialog = new CustomDialogController({
  builder: InputDialog({
    name: this.currentItem.name,
    onSubmit: async (name) => {
      // TODO 實現修改
    }
  }),
  customStyle: true,
  alignment: DialogAlignment.Center
})

Row() {
  Text('編輯')
    .actionButton($r('app.color.common_blue'))
    .onClick(() => {
      this.currentItem = item
      this.dialog.open()
    })

3)完成修改

dialog = new CustomDialogController({
  builder: InputDialog({
    name: this.currentItem.name,
    onSubmit: async (name) => {
      const item = this.currentItem
      item.name = name
      await audioDB.update(item)
      await this.getList()
      this.dialog.close()
    }
  }),
  customStyle: true,
  alignment: DialogAlignment.Center
})

1.4.6. 錄音播放

目標:通過全屏模態框實現錄音信息展示和播放

1)播放組件準備 Audio/components/AudioPlayer.ets 支持播放暫停和進度效果

import { media } from '@kit.MediaKit'
import { fileIo } from '@kit.CoreFileKit'
import { InterviewAudioItem } from '../../../commons/utils/AudioDB'
import { logger } from '../../../commons/utils'

@ComponentV2
  export struct AudioPlayer {
    @Param item: InterviewAudioItem = {} as InterviewAudioItem
    @Local playing: boolean = false
    @Local total: number = 0
    @Local value: number = 0

    avPlayer?: media.AVPlayer

    async startPlay() {
      try {
        const file = fileIo.openSync(this.item.path, fileIo.OpenMode.READ_ONLY)
        const avPlayer = await media.createAVPlayer()
        avPlayer.on('stateChange', state => {
          if (state === 'initialized') {
            avPlayer.prepare()
          } else if (state === 'prepared') {
            avPlayer.loop = true
            this.total = avPlayer.duration
            avPlayer.play()
          }
        })
        // 當前播放時間改變
        avPlayer.on('timeUpdate', (time) => {
          this.value = time
        })
        avPlayer.url = `fd://${file.fd}`
        this.avPlayer = avPlayer
        this.playing = true
      } catch (e) {
        logger.error('startPlay', JSON.stringify(e))
      }
    }

    stopPlay() {
      if (this.avPlayer) {
        this.avPlayer.stop()
        this.avPlayer.release()
        this.playing = false
      }
    }

    aboutToAppear(): void {
      if (this.playing) {
        this.stopPlay()
      }
    }

    build() {
      Column({ space: 20 }) {
        Image($r('app.media.ic_mine_audio'))
          .width(100)
          .aspectRatio(1)
        Text(this.item.name)
          .fontSize(18)

        Row({ space: 20 }) {
          Image(!this.playing ? $r('sys.media.ohos_ic_public_play') : $r('sys.media.ohos_ic_public_pause'))
            .width(24)
            .aspectRatio(1)
            .onClick(() => {
              if (!this.playing) {
                this.startPlay()
              } else {
                this.stopPlay()
              }
            })
          Progress({ value: this.value, total: this.total })
            .layoutWeight(1)
            .margin({ top: 20, bottom: 20 })
        }
        .width('80%')
      }
      .justifyContent(FlexAlign.Center)
        .width('100%')
        .height('100%')
        .backgroundColor($r('app.color.white'))
        .onDisAppear(() => {
          this.stopPlay()
        })
    }
  }

2)綁定全屏模態框

@Builder
PlayerBuilder () {
  Column(){
    AudioPlayer({ item: this.currentItem })
  }
}

@Local isShow: boolean = false

List() {
  ...
}
.width('100%')
.height('100%')
.bindContentCover($$this.isShow, this.PlayerBuilder())

AudioItemComp({
  item: item
})
.onClick(() => {
  this.currentItem = item
  this.isShow = true
})

HarmonyOS賦能資源豐富度建設(第四期)-吳東林

https://developer.huawei.com/consumer/cn/training/classDetail/9fdeeb1a35d64d2fabad3948ae7aab72?type=1?ha_source=hmosclass&ha_sourceId=89000248