|
|
@@ -0,0 +1,1094 @@
|
|
|
+import { common, ConfigurationConstant } from '@kit.AbilityKit';
|
|
|
+import { CommonConstants } from '../common/constants/CommonConstants';
|
|
|
+import { VideoItem } from '../viewmodel/VideoItem';
|
|
|
+import { LazyDataSource } from '../common/util/LazyDataSource';
|
|
|
+import { AppUtil, FileUtil, LogUtil, PreferencesUtil, StrUtil, ToastUtil } from '@pura/harmony-utils';
|
|
|
+import fs from '@ohos.file.fs';
|
|
|
+import { changeMusicCover, findLocalCoverImage, getApiLyric, repairAudioMetadata,
|
|
|
+ searchCover,
|
|
|
+ syncLyricToDB} from '../common/util/MusicTagUtils';
|
|
|
+import { taskpool, util } from '@kit.ArkTS';
|
|
|
+import { extractHwMediaMetadata, FFMpegTags, FFprobeMetadata, Utility } from '../common/util/Utility';
|
|
|
+import { DialogHelper } from '@pura/harmony-dialog';
|
|
|
+import MediaTable from '../common/util/MediaTable';
|
|
|
+import { TrackProgress } from '@abner/track';
|
|
|
+import { FFmpeg, FFProgressMessageParser } from '@sj/ffmpeg';
|
|
|
+import Logger from '../common/util/Logger';
|
|
|
+import { BusinessError } from '@kit.BasicServicesKit';
|
|
|
+import { PlayStatus } from '../common/PlayStatus';
|
|
|
+import { ringtone } from '@kit.RingtoneKit';
|
|
|
+import { uniformTypeDescriptor } from '@kit.ArkData';
|
|
|
+import PermissionUtil from '../common/util/PermissionUtil'
|
|
|
+import { ButtonFancyModifier, ShadowModifier, SymbolGlyphFancyModifier } from '../common/util/AttributeModifierUtil';
|
|
|
+
|
|
|
+// 音频编辑功能
|
|
|
+@Component
|
|
|
+export struct EditAudio {
|
|
|
+ onResult = (_result: boolean,outPath:string) => {
|
|
|
+ }
|
|
|
+ onPlayOrPause = () => {
|
|
|
+ }
|
|
|
+ onBack = () => {
|
|
|
+ }
|
|
|
+ onSeeOutputPath = (outPutPath:string) => {
|
|
|
+
|
|
|
+ }
|
|
|
+ onSliderChange = (value:number) => {
|
|
|
+ }
|
|
|
+ @State isEditing:boolean = false
|
|
|
+ @Prop currentPath: string;
|
|
|
+ @StorageProp('isLandscape') isLandscape: boolean = false;
|
|
|
+ @Link progressValue: number;
|
|
|
+ @Link currentTime: string ;
|
|
|
+ @Link videoUrl: string ;//用于判断当期播放的歌曲是不是和剪辑的歌一样,如果不是 播放控制不跟随更新
|
|
|
+ @State PROGRESS_MAX_VALUE: number = 100;
|
|
|
+ @Link CONTROL_PlayStatus: number;
|
|
|
+ @State bundleName: string = ''
|
|
|
+ @State durationStr:string = ''
|
|
|
+ @State outputPath:string = ''
|
|
|
+ @State startTimeStr:string = '00:00'
|
|
|
+ @State endTimeStr:string = '00:00'
|
|
|
+ // @State dataSource: LazyDataSource<VideoItem> = new LazyDataSource([])
|
|
|
+ @Prop mVideoItem: VideoItem
|
|
|
+ @StorageProp('topRectHeight') topRectHeight: number = 0;
|
|
|
+ @State isDarkMode: boolean = false
|
|
|
+ @State isProcessing: boolean = false;
|
|
|
+ @StorageProp('currentColorMode') @Watch('onColorModeChange') currentMode: number =
|
|
|
+ ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT;
|
|
|
+ @StorageProp('themeColor') themeColor: string = CommonConstants.DEFAULT_THEME_COLOR;
|
|
|
+ context = this.getUIContext().getHostContext() as common.UIAbilityContext
|
|
|
+
|
|
|
+ private table: MediaTable = new MediaTable(this.context);
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ onColorModeChange() {
|
|
|
+ this.isDarkMode = this.currentMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK
|
|
|
+ }
|
|
|
+
|
|
|
+ async aboutToAppear() {
|
|
|
+ await PermissionUtil.activatePermission(this.mVideoItem.filePath)
|
|
|
+ this.bundleName = AppUtil.getBundleName()
|
|
|
+ // 原始时间格式转换(如 "03:37" → "00:03:37.00")
|
|
|
+ const rawDuration = this.mVideoItem.duration || '00:00:00';
|
|
|
+ this.durationStr = convertSecondsToTime(convertTimeToSeconds(rawDuration));
|
|
|
+
|
|
|
+ // 初始化结束时间(标准化格式)
|
|
|
+ this.endTimeStr = this.durationStr;
|
|
|
+ console.info('onecold this.durationStr ='+this.durationStr)
|
|
|
+ this.outputPath = generateOutputPath(this.mVideoItem.filePath,'剪辑',this.currentPath)
|
|
|
+ await new Promise<void>((resolve, reject) => {
|
|
|
+ this.table.getRdbStore(this.context, (err:Error) => {
|
|
|
+ err ? reject(err) : resolve();
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ //导出音频
|
|
|
+ async exportAudio(){
|
|
|
+ if (this.isProcessing) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.isProcessing = true;
|
|
|
+ // 1. 主线程获取音频信息(普通异步函数,无 @Concurrent)
|
|
|
+ const streamInfo = await getAudioStreamInfo(this.mVideoItem.filePath);
|
|
|
+ const task = new taskpool.Task(
|
|
|
+ trimAudio,
|
|
|
+ this.context,
|
|
|
+ this.mVideoItem.filePath,
|
|
|
+ this.startTimeStr,
|
|
|
+ this.endTimeStr,
|
|
|
+ 0,
|
|
|
+ this.outputPath,
|
|
|
+ streamInfo.codecName,
|
|
|
+ streamInfo.sampleRate,
|
|
|
+ streamInfo.sampleFmt,
|
|
|
+ );
|
|
|
+ taskpool.execute(task, taskpool.Priority.HIGH).then((result) => {
|
|
|
+ this.isProcessing = false;
|
|
|
+ if(result){//导出成功后提示
|
|
|
+ this.onResult(true,this.outputPath)
|
|
|
+ this.showSuccess()
|
|
|
+ }else{
|
|
|
+ this.onResult(false,this.outputPath)
|
|
|
+ }
|
|
|
+
|
|
|
+ }).catch((error: Error) => {
|
|
|
+ console.error('trimAudio sync failed:', error);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ async exportAudioForDSF(){
|
|
|
+ if(FileUtil.accessSync(this.outputPath)){
|
|
|
+ this.outputPath = generateOutputPath(this.mVideoItem.filePath,'剪辑',this.currentPath)
|
|
|
+ }
|
|
|
+ // 1. 主线程获取音频信息(普通异步函数,无 @Concurrent)
|
|
|
+ const streamInfo = await getAudioStreamInfo(this.mVideoItem.filePath);
|
|
|
+ const task = new taskpool.Task(
|
|
|
+ handleDSDNative,
|
|
|
+ this.context,
|
|
|
+ this.mVideoItem.filePath,
|
|
|
+ this.startTimeStr,
|
|
|
+ this.endTimeStr,
|
|
|
+ this.outputPath,
|
|
|
+ );
|
|
|
+ taskpool.execute(task, taskpool.Priority.HIGH).then((result) => {
|
|
|
+
|
|
|
+ if(result){//导出成功后提示
|
|
|
+ this.onResult(true,this.outputPath)
|
|
|
+ this.showSuccess()
|
|
|
+ }else{
|
|
|
+ this.onResult(false,this.outputPath)
|
|
|
+ }
|
|
|
+
|
|
|
+ }).catch((error: Error) => {
|
|
|
+ console.error('exportAudioForDSF sync failed:', error);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ showSuccess() {
|
|
|
+
|
|
|
+ try {
|
|
|
+ this.getUIContext().getPromptAction().showDialog({
|
|
|
+ title: '导出成功',
|
|
|
+ message:'剪辑音频完成,导出的路径为:\n\n'
|
|
|
+ + this.outputPath+'\n\n',
|
|
|
+ buttons: [
|
|
|
+ {
|
|
|
+ text: '查看路径',
|
|
|
+ color: '#000000'
|
|
|
+ },
|
|
|
+ {
|
|
|
+ text: '设为铃声',
|
|
|
+ color: '#000000'
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ }, (err, data) => {
|
|
|
+ if (err) {
|
|
|
+ console.error('showDialog err: ' + err);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 根据点击的按钮索引处理不同逻辑
|
|
|
+ if (data.index === 1) { // "设为铃声"按钮的索引为1
|
|
|
+ // ToastUtil.showToast('设置铃声成功')
|
|
|
+ setRingTone(this.context, this.outputPath,FileUtil.getFileName(this.outputPath))
|
|
|
+ }else if (data.index === 0){//查看路径
|
|
|
+ this.onSeeOutputPath(this.outputPath)
|
|
|
+ }
|
|
|
+ console.info('showDialog success callback, click button: ' + data.index);
|
|
|
+ });
|
|
|
+ } catch (error) {
|
|
|
+ let message = (error as BusinessError).message;
|
|
|
+ let code = (error as BusinessError).code;
|
|
|
+ console.error(`showDialog args error code is ${code}, message is ${message}`);
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ build() {
|
|
|
+ Scroll(){
|
|
|
+ Column(){
|
|
|
+ this.topTitleBar()
|
|
|
+ this.buildContent()
|
|
|
+ }
|
|
|
+ .height(this.isLandscape ? 'auto' :'98%')
|
|
|
+ }
|
|
|
+ .height('100%')
|
|
|
+ .width('100%')
|
|
|
+ .backgroundColor($r('app.color.index_background'))
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ @Builder
|
|
|
+ buildContent() {
|
|
|
+ Column({ space: 20 }) {
|
|
|
+ // 文件名显示
|
|
|
+ Row({ space: 10 }) {
|
|
|
+ Image(this.mVideoItem.pixelMapPath)
|
|
|
+ .height(55)
|
|
|
+ .width(55)
|
|
|
+ .clickEffect({level:ClickEffectLevel.LIGHT, scale: 0.6})
|
|
|
+ .borderRadius(12)
|
|
|
+ .alt($r('app.media.llq'))
|
|
|
+ .clip(true)
|
|
|
+ Column(){
|
|
|
+ Text(` ${this.mVideoItem.name || this.mVideoItem.fileName}`)
|
|
|
+ .fontSize(16)
|
|
|
+ .fontColor($r('app.color.text_color'))
|
|
|
+ .textAlign(TextAlign.Start)
|
|
|
+ Text(` ${this.mVideoItem.artist || this.mVideoItem.size}`)
|
|
|
+ .fontSize(16)
|
|
|
+ .fontColor($r('app.color.text_color'))
|
|
|
+ .textAlign(TextAlign.Start)
|
|
|
+ }
|
|
|
+ .layoutWeight(1)
|
|
|
+ .alignItems(HorizontalAlign.Start)
|
|
|
+ .width('100%')
|
|
|
+
|
|
|
+ }
|
|
|
+ .width('90%')
|
|
|
+ .margin({ top: 5})
|
|
|
+ .justifyContent(FlexAlign.Start)
|
|
|
+
|
|
|
+
|
|
|
+ Column({ space: 10 }) {
|
|
|
+ Text('输出文件名:')
|
|
|
+ .fontSize(16)
|
|
|
+ .width('90%')
|
|
|
+ .textAlign(TextAlign.Start)
|
|
|
+ .fontColor($r('app.color.text_color'))
|
|
|
+
|
|
|
+
|
|
|
+ TextArea({ text: getFileNameWithoutExtension(this.outputPath) })
|
|
|
+ .height('auto')
|
|
|
+ .fontSize(14)
|
|
|
+ .fontColor($r('app.color.text_color'))
|
|
|
+ .width('90%')
|
|
|
+ .onEditChange((isEditing: boolean) => {
|
|
|
+ console.info(`onecold isEditing ${isEditing}`);
|
|
|
+ this.isEditing = isEditing;
|
|
|
+
|
|
|
+ })
|
|
|
+ .onChange((val: string) => {
|
|
|
+ if(this.isEditing){
|
|
|
+ console.info('onecold onChange val=' + val)
|
|
|
+ this.outputPath = generateOutputPath(this.mVideoItem.filePath,'',this.currentPath,
|
|
|
+ undefined, val )
|
|
|
+ }
|
|
|
+
|
|
|
+ })
|
|
|
+ }
|
|
|
+ .width('100%')
|
|
|
+ .margin({ top: 5, bottom: 5 })
|
|
|
+ .justifyContent(FlexAlign.Start)
|
|
|
+
|
|
|
+ Column(){
|
|
|
+ TrackProgress({
|
|
|
+ pointerWidth: 20,
|
|
|
+ leftPointerBgColor: Color.Orange,
|
|
|
+ rightPointerBgColor: Color.Orange,
|
|
|
+ trackSelectColor: Color.Orange,
|
|
|
+ trackBgBorder: { width: 1, color: Color.Orange, radius: 5 },
|
|
|
+ onLeftProgress: (progress: number) => {
|
|
|
+ console.log("onecold ===左侧指针进度:" + progress)
|
|
|
+ // 将进度比例转换为时间(秒)
|
|
|
+ const totalSeconds = convertTimeToSeconds(this.durationStr);
|
|
|
+ const newStartSeconds = Math.floor(progress*0.01 * totalSeconds);
|
|
|
+ this.startTimeStr = convertSecondsToTime(newStartSeconds);
|
|
|
+ },
|
|
|
+ onRightProgress: (progress: number) => {
|
|
|
+ console.log("===右侧指针进度:" + progress)
|
|
|
+ const totalSeconds = convertTimeToSeconds(this.durationStr);
|
|
|
+ const newEndSeconds = Math.floor(progress*0.01 * totalSeconds);
|
|
|
+ this.endTimeStr = convertSecondsToTime(newEndSeconds);
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ .width('90%')
|
|
|
+
|
|
|
+ // 开始时间设置区域
|
|
|
+ Row() {
|
|
|
+ Text('开始时间:')
|
|
|
+ .fontSize(16)
|
|
|
+ .fontColor($r('app.color.text_color'))
|
|
|
+
|
|
|
+ Button('-', { type: ButtonType.Circle })
|
|
|
+ .width(40)
|
|
|
+ .height(40)
|
|
|
+ .fontSize(20)
|
|
|
+ .backgroundColor(this.themeColor) // 使用主题色
|
|
|
+ .onClick(() => {
|
|
|
+ const seconds = convertTimeToSeconds(this.startTimeStr);
|
|
|
+ if (seconds > 0.1) { // 避免负数
|
|
|
+ this.startTimeStr = convertSecondsToTime(seconds - 0.1);
|
|
|
+ }
|
|
|
+
|
|
|
+ })
|
|
|
+ .gesture(
|
|
|
+ LongPressGesture({ repeat: true, duration: 50 })
|
|
|
+ .onAction((event?: GestureEvent) => {
|
|
|
+ const seconds = convertTimeToSeconds(this.startTimeStr);
|
|
|
+ if (seconds > 0.1) {
|
|
|
+ this.startTimeStr = convertSecondsToTime(seconds - 0.1);
|
|
|
+ }
|
|
|
+ }))
|
|
|
+
|
|
|
+ Text(this.startTimeStr) // 假设startTime属性存在
|
|
|
+ .fontSize(16)
|
|
|
+ .fontColor($r('app.color.text_color'))
|
|
|
+ .width(80)
|
|
|
+ .textAlign(TextAlign.Center)
|
|
|
+
|
|
|
+ Button('+', { type: ButtonType.Circle })
|
|
|
+ .width(40)
|
|
|
+ .height(40)
|
|
|
+ .fontSize(20)
|
|
|
+ .backgroundColor(this.themeColor) // 使用主题色
|
|
|
+ .onClick(() => {
|
|
|
+ const startSeconds = convertTimeToSeconds(this.startTimeStr);
|
|
|
+ const endSeconds = convertTimeToSeconds(this.endTimeStr);
|
|
|
+ // 限制:开始时间不超过结束时间-0.1秒
|
|
|
+ if (startSeconds < endSeconds - 0.1) {
|
|
|
+ this.startTimeStr = convertSecondsToTime(startSeconds + 0.1);
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .gesture(
|
|
|
+ LongPressGesture({ repeat: true, duration: 50 })
|
|
|
+ .onAction((event?: GestureEvent) => {
|
|
|
+ const startSeconds = convertTimeToSeconds(this.startTimeStr);
|
|
|
+ const endSeconds = convertTimeToSeconds(this.endTimeStr);
|
|
|
+ // 限制:开始时间不超过结束时间-0.1秒
|
|
|
+ if (startSeconds < endSeconds - 0.1) {
|
|
|
+ this.startTimeStr = convertSecondsToTime(startSeconds + 0.1);
|
|
|
+ }
|
|
|
+ }))
|
|
|
+ }
|
|
|
+ .width('90%')
|
|
|
+ .justifyContent(FlexAlign.SpaceBetween)
|
|
|
+
|
|
|
+ // 结束时间设置区域
|
|
|
+ Row() {
|
|
|
+ Text('结束时间:')
|
|
|
+ .fontSize(16)
|
|
|
+ .fontColor($r('app.color.text_color'))
|
|
|
+
|
|
|
+ Button('-', { type: ButtonType.Circle, stateEffect: true })
|
|
|
+ .width(40)
|
|
|
+ .height(40)
|
|
|
+ .fontSize(20)
|
|
|
+ .backgroundColor(this.themeColor) // 使用主题色
|
|
|
+ .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
|
|
|
+ .onClick(() => {
|
|
|
+ const startSeconds = convertTimeToSeconds(this.startTimeStr);
|
|
|
+ const endSeconds = convertTimeToSeconds(this.endTimeStr);
|
|
|
+ // 限制:结束时间不小于开始时间+0.1秒
|
|
|
+ if (endSeconds > startSeconds + 0.1) {
|
|
|
+ this.endTimeStr = convertSecondsToTime(endSeconds - 0.1);
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .gesture(
|
|
|
+ LongPressGesture({ repeat: true, duration: 50 })
|
|
|
+ .onAction((event?: GestureEvent) => {
|
|
|
+ const startSeconds = convertTimeToSeconds(this.startTimeStr);
|
|
|
+ const endSeconds = convertTimeToSeconds(this.endTimeStr);
|
|
|
+ // 限制:结束时间不小于开始时间+0.1秒
|
|
|
+ if (endSeconds > startSeconds + 0.1) {
|
|
|
+ this.endTimeStr = convertSecondsToTime(endSeconds - 0.1);
|
|
|
+ }
|
|
|
+ }))
|
|
|
+
|
|
|
+ Text(this.endTimeStr) // 假设endTime属性存在
|
|
|
+ .fontSize(16)
|
|
|
+ .fontColor($r('app.color.text_color'))
|
|
|
+ .width(80)
|
|
|
+ .textAlign(TextAlign.Center)
|
|
|
+
|
|
|
+ Button('+', { type: ButtonType.Circle, stateEffect: true })
|
|
|
+ .width(40)
|
|
|
+ .height(40)
|
|
|
+ .fontSize(20)
|
|
|
+ .backgroundColor(this.themeColor) // 使用主题色
|
|
|
+ .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
|
|
|
+ .onClick(() => {
|
|
|
+ const totalDuration = convertTimeToSeconds(this.durationStr);
|
|
|
+ const currentEnd = convertTimeToSeconds(this.endTimeStr);
|
|
|
+ if (currentEnd < totalDuration - 0.1) { // 避免超出总时长
|
|
|
+ this.endTimeStr = convertSecondsToTime(currentEnd + 0.1);
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .gesture(
|
|
|
+ LongPressGesture({ repeat: true, duration: 50 })
|
|
|
+ .onAction((event?: GestureEvent) => {
|
|
|
+ const totalDuration = convertTimeToSeconds(this.durationStr);
|
|
|
+ const currentEnd = convertTimeToSeconds(this.endTimeStr);
|
|
|
+ if (currentEnd < totalDuration - 0.1) {
|
|
|
+ this.endTimeStr = convertSecondsToTime(currentEnd + 0.1);
|
|
|
+ }
|
|
|
+ }))
|
|
|
+ }
|
|
|
+ .width('90%')
|
|
|
+ .justifyContent(FlexAlign.SpaceBetween)
|
|
|
+
|
|
|
+ this.playButton()
|
|
|
+
|
|
|
+ // 导出按钮
|
|
|
+ Button({ type: ButtonType.Capsule, stateEffect: true }){
|
|
|
+ Row({ space: 8 }) {
|
|
|
+ if (this.isProcessing) {
|
|
|
+ LoadingProgress()
|
|
|
+ .width(26)
|
|
|
+ .color(Color.Blue)
|
|
|
+ }
|
|
|
+ Text(this.isProcessing ? '导出中...' : '导出音频')
|
|
|
+ .fontSize(14)
|
|
|
+ .fontColor(Color.White)
|
|
|
+
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .width(200)
|
|
|
+ .height(45)
|
|
|
+ .enabled(!this.isProcessing)
|
|
|
+ .backgroundColor(this.isProcessing ? Color.Gray : this.themeColor)
|
|
|
+ .borderRadius(20)
|
|
|
+ .margin({top:20})
|
|
|
+ .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
|
|
|
+ .onClick(() => {
|
|
|
+ if (this.isProcessing) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // 实现导出逻辑
|
|
|
+ if(this.mVideoItem.filePath.toLowerCase().endsWith('.dsf')){
|
|
|
+ this.exportAudioForDSF();
|
|
|
+ }else{
|
|
|
+ this.exportAudio();
|
|
|
+ }
|
|
|
+
|
|
|
+ })
|
|
|
+ }
|
|
|
+ .width('100%')
|
|
|
+ .padding({ top: 20, bottom: 20 })
|
|
|
+ .alignItems(HorizontalAlign.Center)
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ @Builder
|
|
|
+ playButton() {
|
|
|
+ Row(){
|
|
|
+ Button({type:ButtonType.Circle,stateEffect:true}){
|
|
|
+ Column() {
|
|
|
+ Image(this.CONTROL_PlayStatus === PlayStatus.PLAY&&this.videoUrl==this.mVideoItem.filePath ?
|
|
|
+ $r('app.media.hm_pause')
|
|
|
+ : $r('app.media.hm_play2'))
|
|
|
+ .width(38)
|
|
|
+ .clickEffect({level:ClickEffectLevel.LIGHT, scale: 0.5})
|
|
|
+ .fillColor(Color.White)
|
|
|
+ .aspectRatio(CommonConstants.ASPECT_RATIO)
|
|
|
+ .onClick(async () => {
|
|
|
+ this.onPlayOrPause()
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+ .backgroundColor(this.themeColor)
|
|
|
+
|
|
|
+ Text(this.videoUrl==this.mVideoItem.filePath?this.currentTime:'00:00')
|
|
|
+ .fontSize($r('app.float.slider_font_size'))
|
|
|
+ .fontColor($r('app.color.text_color'))
|
|
|
+ .margin(3)
|
|
|
+ Slider({
|
|
|
+ value: this.videoUrl==this.mVideoItem.filePath?this.progressValue:0,
|
|
|
+ min: 0,
|
|
|
+ max: this.PROGRESS_MAX_VALUE,
|
|
|
+ step: 1,
|
|
|
+ style: SliderStyle.OutSet
|
|
|
+ })
|
|
|
+ .width('600px')
|
|
|
+ // .blockColor('rgba(255,255,255,1)')
|
|
|
+ // .trackColor('rgba(255,255,255,0.3)')
|
|
|
+ // .selectedColor($r('app.color.index_background'))
|
|
|
+ .trackThickness(3)
|
|
|
+ .layoutWeight(1)
|
|
|
+ .margin({ left:1 })
|
|
|
+ .showSteps(false)
|
|
|
+ .showTips(true)
|
|
|
+ .onChange((value: number, mode: SliderChangeMode) => {
|
|
|
+ this.onSliderChange(value)
|
|
|
+ })
|
|
|
+ Text(this.durationStr)
|
|
|
+ .fontSize($r('app.float.slider_font_size'))
|
|
|
+ .fontColor($r('app.color.text_color'))
|
|
|
+ .margin(3)
|
|
|
+ .margin({ left: 2 })
|
|
|
+ }
|
|
|
+ .width('90%')
|
|
|
+ .margin({top:10})
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ @Builder
|
|
|
+ topTitleBar(){
|
|
|
+ Column() {
|
|
|
+ Row({ space: 15 }) {
|
|
|
+
|
|
|
+ //左侧滑动按钮
|
|
|
+ Button({ type: ButtonType.Circle, stateEffect: true }) {
|
|
|
+ SymbolGlyph($r('sys.symbol.chevron_left'))
|
|
|
+ .attributeModifier(new SymbolGlyphFancyModifier(25, '', ''))
|
|
|
+ }
|
|
|
+ .attributeModifier(new ButtonFancyModifier(40, 40))
|
|
|
+ .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
|
|
|
+ .animation({ duration: 300, curve: Curve.Ease })
|
|
|
+ .onClick(() => {
|
|
|
+ this.onBack()
|
|
|
+
|
|
|
+ })
|
|
|
+ .attributeModifier(new ShadowModifier())
|
|
|
+ .zIndex(0)
|
|
|
+
|
|
|
+ Text('剪辑音频')
|
|
|
+ .margin({left:3,right:10})
|
|
|
+ .fontColor($r('app.color.text_color'))
|
|
|
+ .fontSize(19)
|
|
|
+ .maxLines(1)
|
|
|
+ .textOverflow({ overflow: TextOverflow.MARQUEE })//超长滚动
|
|
|
+ .layoutWeight(1)
|
|
|
+ .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
|
|
|
+
|
|
|
+ }
|
|
|
+ }
|
|
|
+ .padding({ top: this.topRectHeight, left: 10, right: 10,bottom:12 })
|
|
|
+ .width('100%')
|
|
|
+ }
|
|
|
+
|
|
|
+ @Builder
|
|
|
+ oldTopTitleBar() {
|
|
|
+ // 顶部安全区和自定义标题栏
|
|
|
+ Column() {
|
|
|
+ // 顶部安全区
|
|
|
+ Blank()
|
|
|
+ .height(this.topRectHeight)
|
|
|
+ .backgroundColor(this.isDarkMode ? $r('app.color.title_bar_bg') : this.themeColor)
|
|
|
+ .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP])
|
|
|
+ // 自定义标题栏(Stack实现绝对居中)
|
|
|
+ Stack() {
|
|
|
+ // 居中标题
|
|
|
+ Text('剪辑音频')
|
|
|
+ .fontSize(18)
|
|
|
+ .fontColor(Color.White)
|
|
|
+ .align(Alignment.Center)
|
|
|
+ // 左右按钮
|
|
|
+ Row() {
|
|
|
+ Image($r('app.media.left_back_white'))
|
|
|
+ .width(26)
|
|
|
+ .height(26)
|
|
|
+ .margin({ left: 12, right: 8 })
|
|
|
+ .onClick(() => {
|
|
|
+ this.onBack()
|
|
|
+ })
|
|
|
+ Blank().flexGrow(1)
|
|
|
+ Blank().width(32)
|
|
|
+ }
|
|
|
+ .height(48)
|
|
|
+ .width('100%')
|
|
|
+ .alignItems(VerticalAlign.Center)
|
|
|
+ }
|
|
|
+ .height(48)
|
|
|
+ .width('100%')
|
|
|
+ .backgroundColor(this.isDarkMode ? $r('app.color.title_bar_bg') : this.themeColor)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+//剪辑音频
|
|
|
+// 辅助函数:通过ffprobe获取音频流信息
|
|
|
+// 定义音频流信息接口(鸿蒙支持interface)
|
|
|
+interface AudioStreamInfo {
|
|
|
+ codecName: string;
|
|
|
+ sampleRate: string;
|
|
|
+ sampleFmt: string;
|
|
|
+}
|
|
|
+
|
|
|
+async function getAudioStreamInfo(inputPath: string): Promise<AudioStreamInfo> {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ inputPath = FileUtil.getFilePath(inputPath);
|
|
|
+ const probeCommands = [
|
|
|
+ 'ffprobe',
|
|
|
+ '-v', 'error',
|
|
|
+ '-select_streams', 'a:0',
|
|
|
+ '-show_entries', 'stream=codec_name,sample_rate,sample_fmt',
|
|
|
+ '-of', 'json',
|
|
|
+ inputPath
|
|
|
+ ];
|
|
|
+
|
|
|
+ let outputJson = "";
|
|
|
+
|
|
|
+ FFmpeg.execute(probeCommands, {
|
|
|
+ logCallback: (logLevel: number, logMessage: string) => {
|
|
|
+ console.log(`[${logLevel}] ${logMessage}`);
|
|
|
+ },
|
|
|
+ outputCallback: (message: string) => {
|
|
|
+ outputJson += message;
|
|
|
+ },
|
|
|
+ }).then(() => {
|
|
|
+ try {
|
|
|
+ const info: FFprobeMetadata = JSON.parse(outputJson);
|
|
|
+ const stream = info.streams?.[0];
|
|
|
+
|
|
|
+ if (!stream) {
|
|
|
+ reject(new Error("未找到音频流"));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ resolve({
|
|
|
+ codecName: stream.codec_name || "",
|
|
|
+ sampleRate: stream.sample_rate || "44100",
|
|
|
+ sampleFmt: stream.sample_fmt || "s16"
|
|
|
+ });
|
|
|
+ } catch (e) {
|
|
|
+ console.error(`onecold 解析音频信息失败: ${e}`);
|
|
|
+ reject(new Error(`解析音频信息失败`));
|
|
|
+ }
|
|
|
+ }).catch((err: Error) => {
|
|
|
+ console.error(`onecold FFprobe执行错误: ${err.message}`);
|
|
|
+ reject(err);
|
|
|
+ });
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// 主函数:跨格式音频剪辑(修复流层duration和duration_ts)
|
|
|
+@Concurrent
|
|
|
+export async function trimAudio(
|
|
|
+ context: Context,
|
|
|
+ inputPath: string,
|
|
|
+ startTime: string,
|
|
|
+ endTime: string,
|
|
|
+ streamIndex: number = 0,
|
|
|
+ outPath?: string,
|
|
|
+ codecName?: string,
|
|
|
+ sampleRate?: string,
|
|
|
+ sampleFmt?: string
|
|
|
+): Promise<string> {
|
|
|
+ inputPath = FileUtil.getFilePath(inputPath);
|
|
|
+ // 生成输出路径
|
|
|
+ let outputPath = outPath ?? (() => {
|
|
|
+ const dir = inputPath.substring(0, inputPath.lastIndexOf('/') + 1);
|
|
|
+ const fullName = inputPath.substring(inputPath.lastIndexOf('/') + 1);
|
|
|
+ const dotIndex = fullName.lastIndexOf('.');
|
|
|
+ const name = dotIndex === -1 ? fullName : fullName.substring(0, dotIndex);
|
|
|
+ const ext = dotIndex === -1 ? '' : fullName.substring(dotIndex);
|
|
|
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
|
+ return `${dir}${name}_${timestamp}_trimmed${ext}`;
|
|
|
+ })();
|
|
|
+ // 步骤2:从路径提取文件扩展名
|
|
|
+ const getFileExtension = (path: string): string => {
|
|
|
+ const lastDotIndex = path.lastIndexOf('.');
|
|
|
+ if (lastDotIndex === -1 || lastDotIndex === path.length - 1) {
|
|
|
+ return ''; // 无扩展名
|
|
|
+ }
|
|
|
+ return path.substring(lastDotIndex + 1).toLowerCase(); // 如"mp3"
|
|
|
+ };
|
|
|
+ const format = getFileExtension(inputPath);
|
|
|
+ console.info(` onecold format: ${format}`);
|
|
|
+ console.info(` onecold codecName: ${codecName}`);
|
|
|
+ console.info(` onecold sampleFmt: ${sampleFmt}`);
|
|
|
+ console.info(` onecold sampleRate: ${sampleRate}`);
|
|
|
+ codecName = codecName ||format
|
|
|
+ sampleFmt = sampleFmt ||'s32'
|
|
|
+ sampleRate = sampleRate ||'48000'
|
|
|
+ try {
|
|
|
+
|
|
|
+ // 步骤2:根据格式动态生成FFmpeg命令
|
|
|
+ const baseCommands = [
|
|
|
+ 'ffmpeg',
|
|
|
+ '-i', inputPath,
|
|
|
+ '-map', `0:${streamIndex}`,
|
|
|
+ '-ss', startTime,
|
|
|
+ '-to', endTime,
|
|
|
+ '-fflags', '+genpts', // 所有格式通用:重新生成时间戳
|
|
|
+ '-reset_timestamps', '1', // 所有格式通用:重置时间戳起点为0
|
|
|
+ '-map_metadata', '-1', // 所有格式通用:清除原始元数据
|
|
|
+ ];
|
|
|
+
|
|
|
+ let formatSpecificCommands: string[] = [];
|
|
|
+
|
|
|
+ switch (codecName.toLowerCase()) {
|
|
|
+ // 1. FLAC格式:必须重新编码以重置帧时间戳
|
|
|
+ case 'flac':
|
|
|
+ formatSpecificCommands = [
|
|
|
+ '-c:a', 'flac', // 重新编码FLAC
|
|
|
+ '-sample_fmt', sampleFmt, // 保持原始采样格式(如s32)
|
|
|
+ '-ar', sampleRate, // 保持原始采样率(如48000)
|
|
|
+ ];
|
|
|
+ break;
|
|
|
+
|
|
|
+ // 2. MP3格式:禁用XING帧头,优先流复制,失败则重新编码
|
|
|
+ case 'mp3':
|
|
|
+ formatSpecificCommands = [
|
|
|
+ '-c:a', 'copy', // 优先流复制(快速)
|
|
|
+ '-write_xing', '0', // 禁用XING帧头(关键修复)
|
|
|
+ ];
|
|
|
+ // 若流复制失败(如MP3帧损坏),降级为重新编码
|
|
|
+ formatSpecificCommands.push('-c:a', 'libmp3lame'); // 备选:重新编码
|
|
|
+ break;
|
|
|
+
|
|
|
+ // 3. AAC格式(常见于MP4/M4A):流复制+MP4优化
|
|
|
+ case 'aac':
|
|
|
+ formatSpecificCommands = [
|
|
|
+ '-c:a', 'copy', // 流复制
|
|
|
+ '-movflags', '+faststart', // MP4格式优化:确保时长正确
|
|
|
+ ];
|
|
|
+ break;
|
|
|
+
|
|
|
+ // 4. WAV格式:无损流复制(无压缩帧头问题)
|
|
|
+ case 'pcm_s16le': // WAV的PCM编码
|
|
|
+ case 'pcm_s32le':
|
|
|
+ formatSpecificCommands = [
|
|
|
+ '-c:a', 'copy', // 直接流复制
|
|
|
+ ];
|
|
|
+ break;
|
|
|
+ // 新增:DSF格式处理(codecName为dsd_lsbf/dsd_msbf/dsf)
|
|
|
+ case 'dsd_lsbf':
|
|
|
+ case 'dsd_msbf':
|
|
|
+ case 'dsf':
|
|
|
+ // 方案1:转码为PCM(WAV容器,兼容性最佳)
|
|
|
+ formatSpecificCommands = [
|
|
|
+ '-c:a', 'pcm_s24le', // 24位PCM编码
|
|
|
+ '-ar', '48000', // 保持原始采样率(如352800Hz)
|
|
|
+ '-sample_fmt', 's24', // 采样格式
|
|
|
+ '-f', 'wav', // 输出为WAV
|
|
|
+ ];
|
|
|
+ // 若原扩展为.dsf,强制修改输出扩展为.wav(避免格式混淆)
|
|
|
+ if (outputPath.endsWith('.dsf') && !outPath) {
|
|
|
+ outputPath = outputPath.replace('.dsf', '.wav');
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ // 5. 其他格式(如OGG/ALAC):默认重新编码为原格式
|
|
|
+ default:
|
|
|
+ formatSpecificCommands = [
|
|
|
+ '-c:a', codecName, // 使用原编码器重新编码
|
|
|
+ '-sample_fmt', sampleFmt,
|
|
|
+ '-ar', sampleRate,
|
|
|
+ ];
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 拼接完整命令
|
|
|
+ const commands = [...baseCommands, ...formatSpecificCommands, outputPath];
|
|
|
+
|
|
|
+ // 步骤3:执行FFmpeg剪辑
|
|
|
+ await FFmpeg.execute(commands, {
|
|
|
+ logCallback: (logLevel, logMessage) =>
|
|
|
+ console.log(`[${logLevel}] ${logMessage}`),
|
|
|
+ progressCallback: (message) =>
|
|
|
+ console.log(`[progress] ${JSON.stringify(FFProgressMessageParser.parse(message))}`),
|
|
|
+ });
|
|
|
+
|
|
|
+ // 步骤4:媒体入库(保持原逻辑)
|
|
|
+ console.info(` 音频剪辑成功,保存路径: ${outputPath}`);
|
|
|
+ const table: MediaTable = new MediaTable(context);
|
|
|
+ await new Promise<void>((resolve, reject) => {
|
|
|
+ table.getRdbStore(context, (err: Error) => err ? reject(err) : resolve());
|
|
|
+ });
|
|
|
+ if (Utility.isMeidaByExtension(outputPath)) {
|
|
|
+ const mediaItem = await Utility.uriGetMusicAssetsFromFile(
|
|
|
+ context, outputPath, CommonConstants.TYPE_LOCAL, true
|
|
|
+ );
|
|
|
+ table.insert(mediaItem, () => {}, '');
|
|
|
+ }
|
|
|
+
|
|
|
+ return outputPath;
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ const errorMsg = error instanceof Error ? error.message : String(error);
|
|
|
+ console.error(` 音频剪辑失败: ${errorMsg}`);
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 秒数转智能时间格式(自动省略小时00)
|
|
|
+function convertSecondsToTime(totalSeconds: number): string {
|
|
|
+ const hours = Math.floor(totalSeconds / 3600);
|
|
|
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
|
+ const seconds = totalSeconds % 60;
|
|
|
+
|
|
|
+ // 格式化秒数,去掉末尾的 .0
|
|
|
+ const formatSeconds = (sec: number): string => {
|
|
|
+ return Number.isInteger(sec) ?
|
|
|
+ sec.toString().padStart(2, '0') :
|
|
|
+ sec.toFixed(1).padStart(4, '0');
|
|
|
+ };
|
|
|
+
|
|
|
+ // 格式化为 MM:SS.s 或 HH:MM:SS.s(小时为0时省略)
|
|
|
+ if (hours > 0) {
|
|
|
+ return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${formatSeconds(seconds)}`;
|
|
|
+ } else {
|
|
|
+ return `${minutes.toString().padStart(2, '0')}:${formatSeconds(seconds)}`;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 时间字符串解析(兼容 HH:MM:SS.s 和 MM:SS.s 格式)
|
|
|
+function convertTimeToSeconds(timeStr: string): number {
|
|
|
+ const parts = timeStr.split(':');
|
|
|
+
|
|
|
+ // 根据冒号数量判断格式
|
|
|
+ switch (parts.length) {
|
|
|
+ case 1: // 仅秒数(如 "37.5")
|
|
|
+ return parseFloat(parts[0]);
|
|
|
+ case 2: // MM:SS.s 格式
|
|
|
+ return parseInt(parts[0]) * 60 + parseFloat(parts[1]);
|
|
|
+ case 3: // HH:MM:SS.s 格式
|
|
|
+ return parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseFloat(parts[2]);
|
|
|
+ default:
|
|
|
+ throw new Error(`Invalid time format: ${timeStr}`);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 自动生成输出路径,支持自定义文件名和灵活的后缀规则
|
|
|
+ * @param inputPath 输入文件路径(如 `/data/storage/audio.mp3` )
|
|
|
+ * @param suffix 可选后缀(如 "_trimmed"),customName存在时无效
|
|
|
+ * @param downloadPath 下载目录路径
|
|
|
+ * @param outputExt 可选输出后缀名(如 ".mp3")
|
|
|
+ * @param customName 自定义文件名(存在时忽略时间戳和suffix)
|
|
|
+ * @returns 输出路径(customName存在时格式:`/data/storage/customName.ext` )
|
|
|
+ */
|
|
|
+export function generateOutputPath(
|
|
|
+ inputPath: string,
|
|
|
+ suffix: string = "",
|
|
|
+ downloadPath: string,
|
|
|
+ outputExt?: string,
|
|
|
+ customName?: string
|
|
|
+): string {
|
|
|
+ // 解析路径基础组件
|
|
|
+ const dir = downloadPath.endsWith('/') ? downloadPath : downloadPath + '/';
|
|
|
+ const fullName = safeDecode(inputPath.substring(inputPath.lastIndexOf('/') + 1));
|
|
|
+ const dotIndex = fullName.lastIndexOf('.');
|
|
|
+
|
|
|
+ if(StrUtil.isNotEmpty(outputExt)&&!outputExt?.startsWith('.')){
|
|
|
+ outputExt = '.'+outputExt//兼容输出扩展名有没有包含.
|
|
|
+ }
|
|
|
+ // 确定最终文件名和扩展名
|
|
|
+ const finalName = customName ?? (dotIndex === -1 ? fullName : fullName.substring(0, dotIndex));
|
|
|
+ const ext = outputExt ?? (dotIndex === -1 ? '' : fullName.substring(dotIndex));
|
|
|
+ let timestamp:string=''
|
|
|
+ // 核心逻辑分支
|
|
|
+ let basePath: string;
|
|
|
+ if (customName !== undefined) {
|
|
|
+ // 模式1:完全自定义(忽略时间戳和suffix)
|
|
|
+ basePath = `${dir}${finalName}${ext}`;
|
|
|
+ } else {
|
|
|
+ // 模式2:自动生成带时间戳的路径
|
|
|
+ timestamp = `${new Date().getFullYear()}${(new Date().getMonth() + 1).toString().padStart(2, '0')}${new Date().getDate().toString().padStart(2, '0')}_${new Date().getHours().toString().padStart(2, '0')}${new Date().getMinutes().toString().padStart(2, '0')}`;
|
|
|
+ basePath = `${dir}${finalName}_${timestamp}${suffix}${ext}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理文件重名冲突
|
|
|
+ let counter = 0;
|
|
|
+ let outputPath = basePath;
|
|
|
+ while (fileExists(outputPath)) {
|
|
|
+ counter++;
|
|
|
+ outputPath = customName !== undefined
|
|
|
+ ? `${dir}${finalName}_${counter}${ext}` // 自定义模式追加数字
|
|
|
+ : `${dir}${finalName}_${timestamp}${suffix}_${counter}${ext}`; // 自动模式追加数字
|
|
|
+ }
|
|
|
+
|
|
|
+ return outputPath;
|
|
|
+}
|
|
|
+/**
|
|
|
+ * 获取文件名(不含后缀),自动处理URL编码
|
|
|
+ * @param filePath 文件路径(如 `/data/测试%20文件.txt` 或 `example.mkv` )
|
|
|
+ * @returns 纯文件名(如 `测试 文件` 或 `example`)
|
|
|
+ */
|
|
|
+export function getFileNameWithoutExtension(filePath: string): string {
|
|
|
+ // 1. 提取最后一个'/'后的内容(兼容Windows路径)
|
|
|
+ const fileNameWithExt = filePath.substring(filePath.lastIndexOf('/') + 1);
|
|
|
+
|
|
|
+ // 2. 解码URL编码(如 %20 → 空格)
|
|
|
+ const decodedName = decodeURIComponent(fileNameWithExt);
|
|
|
+
|
|
|
+ // 3. 去除后缀(找到最后一个点号)
|
|
|
+ const lastDotIndex = decodedName.lastIndexOf('.');
|
|
|
+
|
|
|
+ // 4. 返回结果(无后缀时返回完整名称)
|
|
|
+ return lastDotIndex === -1 ? decodedName : decodedName.substring(0, lastDotIndex);
|
|
|
+}
|
|
|
+
|
|
|
+function safeDecode(encodedStr: string) {
|
|
|
+ if (/%[0-9A-Fa-f]{2}/.test(encodedStr)) {
|
|
|
+ return decodeURIComponent(encodedStr);
|
|
|
+ }
|
|
|
+ return encodedStr; // 非编码字符串直接返回
|
|
|
+}
|
|
|
+
|
|
|
+function fileExists(path: string): boolean {
|
|
|
+ try {
|
|
|
+ // 这里假设有一个FileUtil.accessSync 方法
|
|
|
+ // 实际项目中可能是fs.existsSync(path) 或其他API
|
|
|
+ return fs.accessSync(path)
|
|
|
+ } catch (e) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+// 专门处理DSF格式的函数
|
|
|
+@Concurrent
|
|
|
+async function handleDSFAudio(
|
|
|
+ context: Context,
|
|
|
+ inputPath: string,
|
|
|
+ startTime: string,
|
|
|
+ endTime: string,
|
|
|
+ outputPath: string
|
|
|
+): Promise<string> {
|
|
|
+ try {
|
|
|
+ inputPath = FileUtil.getFilePath(inputPath);
|
|
|
+ // 方案1:转换为高精度PCM WAV格式(推荐)
|
|
|
+ const wavOutputPath = outputPath.replace('.dsf', '.wav');
|
|
|
+
|
|
|
+ console.info(`onecold DSF转码开始: ${inputPath} -> ${wavOutputPath}`);
|
|
|
+
|
|
|
+ // 将DSF转换为WAV进行剪辑
|
|
|
+ const convertCommands = [
|
|
|
+ 'ffmpeg',
|
|
|
+ '-i', inputPath,
|
|
|
+ '-ss', startTime,
|
|
|
+ '-to', endTime,
|
|
|
+ '-c:a', 'pcm_s24le', // 24位PCM,保持高音质
|
|
|
+ '-ar', '48000', // 设置采样率(DSF通常很高,需要降低)
|
|
|
+ '-ac', '2', // 立体声
|
|
|
+ '-f', 'wav', // 输出为WAV格式
|
|
|
+ '-y',
|
|
|
+ wavOutputPath
|
|
|
+ ];
|
|
|
+
|
|
|
+ await FFmpeg.execute(convertCommands, {
|
|
|
+ logCallback: (logLevel, logMessage) =>
|
|
|
+ console.log(`[${logLevel}] DSF转码: ${logMessage}`),
|
|
|
+ progressCallback: (message) =>
|
|
|
+ console.log(`[progress] DSF转码: ${JSON.stringify(FFProgressMessageParser.parse(message))}`),
|
|
|
+ });
|
|
|
+
|
|
|
+ console.info(`onecold DSF转码成功: ${wavOutputPath}`);
|
|
|
+
|
|
|
+ // 媒体入库
|
|
|
+ const table: MediaTable = new MediaTable(context);
|
|
|
+ await new Promise<void>((resolve, reject) => {
|
|
|
+ table.getRdbStore(context, (err: Error) => err ? reject(err) : resolve());
|
|
|
+ });
|
|
|
+
|
|
|
+ if (Utility.isMeidaByExtension(wavOutputPath)) {
|
|
|
+ const mediaItem = await Utility.uriGetMusicAssetsFromFile(
|
|
|
+ context, wavOutputPath, CommonConstants.TYPE_LOCAL, true
|
|
|
+ );
|
|
|
+ table.insert(mediaItem, () => {}, '');
|
|
|
+ }
|
|
|
+
|
|
|
+ return wavOutputPath;
|
|
|
+ } catch (error) {
|
|
|
+ const errorMsg = error instanceof Error ? error.message : String(error);
|
|
|
+ console.error(`DSF 转码失败: ${errorMsg}`);
|
|
|
+ return ''
|
|
|
+ // 方案2:如果转码失败,尝试使用DSD原生处理
|
|
|
+ // return await handleDSDNative(context, inputPath, startTime, endTime, outputPath);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 备选方案:DSD原生处理(如果FFmpeg支持)
|
|
|
+@Concurrent
|
|
|
+async function handleDSDNative(
|
|
|
+ context: Context,
|
|
|
+ inputPath: string,
|
|
|
+ startTime: string,
|
|
|
+ endTime: string,
|
|
|
+ outputPath: string
|
|
|
+): Promise<string> {
|
|
|
+ try {
|
|
|
+ inputPath = FileUtil.getFilePath(inputPath);
|
|
|
+ // 尝试使用DSD原生支持
|
|
|
+ const dsdCommands = [
|
|
|
+ 'ffmpeg',
|
|
|
+ '-i', inputPath,
|
|
|
+ '-ss', startTime,
|
|
|
+ '-to', endTime,
|
|
|
+ '-c:a', 'copy', // 尝试直接复制
|
|
|
+ '-f', 'dsf', // 强制输出为DSF格式
|
|
|
+ '-y',
|
|
|
+ outputPath
|
|
|
+ ];
|
|
|
+
|
|
|
+ await FFmpeg.execute(dsdCommands, {
|
|
|
+ logCallback: (logLevel, logMessage) =>
|
|
|
+ console.log(`[${logLevel}] DSD原生: ${logMessage}`),
|
|
|
+ progressCallback: (message) =>
|
|
|
+ console.log(`[progress] DSD原生: ${JSON.stringify(FFProgressMessageParser.parse(message))}`),
|
|
|
+ });
|
|
|
+
|
|
|
+ console.info(`onecold DSD原生剪辑成功: ${outputPath}`);
|
|
|
+
|
|
|
+ // 媒体入库
|
|
|
+ const table: MediaTable = new MediaTable(context);
|
|
|
+ await new Promise<void>((resolve, reject) => {
|
|
|
+ table.getRdbStore(context, (err: Error) => err ? reject(err) : resolve());
|
|
|
+ });
|
|
|
+
|
|
|
+ if (Utility.isMeidaByExtension(outputPath)) {
|
|
|
+ const mediaItem = await Utility.uriGetMusicAssetsFromFile(
|
|
|
+ context, outputPath, CommonConstants.TYPE_LOCAL, true
|
|
|
+ );
|
|
|
+ table.insert(mediaItem, () => {}, '');
|
|
|
+ }
|
|
|
+
|
|
|
+ return outputPath;
|
|
|
+ } catch (error) {
|
|
|
+ const errorMsg = error instanceof Error ? error.message : String(error);
|
|
|
+ console.error(`DSD 原生剪辑失败: ${errorMsg}`);
|
|
|
+
|
|
|
+ // 最终方案:转换为FLAC格式
|
|
|
+ const flacOutputPath = outputPath.replace('.dsf', '.flac');
|
|
|
+
|
|
|
+ const flacCommands = [
|
|
|
+ 'ffmpeg',
|
|
|
+ '-i', inputPath,
|
|
|
+ '-ss', startTime,
|
|
|
+ '-to', endTime,
|
|
|
+ '-c:a', 'flac',
|
|
|
+ '-compression_level', '5', // 高质量FLAC
|
|
|
+ '-ar', '48000',
|
|
|
+ '-ac', '2',
|
|
|
+ '-y',
|
|
|
+ flacOutputPath
|
|
|
+ ];
|
|
|
+
|
|
|
+ await FFmpeg.execute(flacCommands, {
|
|
|
+ logCallback: (logLevel, logMessage) =>
|
|
|
+ console.log(`[${logLevel}] DSF转FLAC: ${logMessage}`),
|
|
|
+ progressCallback: (message) =>
|
|
|
+ console.log(`[progress] DSF转FLAC: ${JSON.stringify(FFProgressMessageParser.parse(message))}`),
|
|
|
+ });
|
|
|
+ const table: MediaTable = new MediaTable(context);
|
|
|
+ // 媒体入库
|
|
|
+ await new Promise<void>((resolve, reject) => {
|
|
|
+ const table: MediaTable = new MediaTable(context);
|
|
|
+ table.getRdbStore(context, (err: Error) => err ? reject(err) : resolve());
|
|
|
+ });
|
|
|
+
|
|
|
+ if (Utility.isMeidaByExtension(flacOutputPath)) {
|
|
|
+ const mediaItem = await Utility.uriGetMusicAssetsFromFile(
|
|
|
+ context, flacOutputPath, CommonConstants.TYPE_LOCAL, true
|
|
|
+ );
|
|
|
+ table.insert(mediaItem, () => {}, '');
|
|
|
+ }
|
|
|
+
|
|
|
+ return flacOutputPath;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+//设置铃声
|
|
|
+export async function setRingTone(context: Context,path:string, name:string) {
|
|
|
+ if (StrUtil.isEmpty(path) || StrUtil.isEmpty(name)) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+ // 确定后的逻辑
|
|
|
+ let ringtoneTypeList: Array<ringtone.RingtoneType> = ringtone.getSupportedRingtoneTypes();
|
|
|
+ LogUtil.info('onecold getSupportedRingtoneTypes : ' + JSON.stringify(ringtoneTypeList));
|
|
|
+ let dataTypeList: Array<uniformTypeDescriptor.UniformDataType> =
|
|
|
+ ringtone.getSupportedDataTypes(ringtone.RingtoneType.NOTIFICATION);
|
|
|
+ LogUtil.info('onecold getSupportedDataTypes: ' + JSON.stringify(dataTypeList));
|
|
|
+
|
|
|
+
|
|
|
+ //let fileName: string = audioPath.substring(audioPath.lastIndexOf('/') + 1, audioPath.lastIndexOf('.'));
|
|
|
+ await ringtone.startRingtoneSetting(context as common.UIAbilityContext
|
|
|
+ ,path, name).then(res => {
|
|
|
+ LogUtil.info('onecold setFlag :' + res);
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+
|