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