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赋能资源丰富度建设(第四期)-吴东林
developer.huawei.com/consumer/cn…