|
|
@@ -17,12 +17,11 @@ import { dataSharePredicates, uniformTypeDescriptor } from '@kit.ArkData';
|
|
|
import { systemShare } from '@kit.ShareKit';
|
|
|
import { fileUri, picker } from '@kit.CoreFileKit';
|
|
|
import { common, UIAbility, Want } from '@kit.AbilityKit';
|
|
|
-import { CommonConstants } from '../constants/CommonConstants';
|
|
|
+import { CommonConstants, STR_LOCK_VIDEO, VIP_FILEPATH } from '../constants/CommonConstants';
|
|
|
import { window } from '@kit.ArkUI';
|
|
|
import { bundleManager } from '@kit.AbilityKit'
|
|
|
import { pinyin4js } from '@ohos/pinyin4js';
|
|
|
import { VipData } from '../../viewmodel/VipData';
|
|
|
-import { LocalMusic } from '../../view/LocalMusic';
|
|
|
import { VipPage } from '../../pages/VipPage';
|
|
|
import { FFmpeg, FFProgressMessageParser } from '@sj/ffmpeg';
|
|
|
|
|
|
@@ -67,6 +66,12 @@ interface FFprobeStream {
|
|
|
sample_rate?: string; // 采样率
|
|
|
bit_rate?: string; // 比特率
|
|
|
disposition?: StreamDisposition; // 添加disposition属性
|
|
|
+
|
|
|
+ bits_per_raw_sample?: string; // 位深
|
|
|
+ channels?: string; // 声道数
|
|
|
+ channel_layout?: string; // 声道布局
|
|
|
+ start_time?: string; // 起始播放
|
|
|
+
|
|
|
}
|
|
|
|
|
|
interface StreamDisposition {
|
|
|
@@ -138,10 +143,8 @@ export class Utility {
|
|
|
let isNoble = false
|
|
|
if(StrUtil.isNotEmpty(expireDate)){
|
|
|
if(expireDate===VipPage.FOREVER_DATE){
|
|
|
- PreferencesUtil.putSync('isForever',true)
|
|
|
isNoble = true
|
|
|
}else{
|
|
|
- PreferencesUtil.putSync('isForever',false)
|
|
|
const currentDate = new Date();
|
|
|
const targetDate = DateUtil.getFormatDate(expireDate)
|
|
|
|
|
|
@@ -407,8 +410,9 @@ export class Utility {
|
|
|
return false;
|
|
|
}
|
|
|
|
|
|
+
|
|
|
// 获取缩略图
|
|
|
- static async getFetchFrameByTime(filePath: string) {
|
|
|
+ static async getFetchFrameByTime(filePath: string,time?:number) {
|
|
|
if(Utility.isMusicByExtension(filePath)){
|
|
|
return undefined
|
|
|
}
|
|
|
@@ -421,6 +425,10 @@ export class Utility {
|
|
|
avImageGenerator.fdSrc = avFileDescriptor;
|
|
|
// 初始化入参
|
|
|
let timeUs = 0
|
|
|
+ console.info('onecold time='+time)
|
|
|
+ if(time){
|
|
|
+ timeUs = (time > 0) ? time*60 : 0
|
|
|
+ }
|
|
|
let queryOption = media.AVImageQueryOptions.AV_IMAGE_QUERY_NEXT_SYNC
|
|
|
let param: media.PixelMapParams = {
|
|
|
width : 300,
|
|
|
@@ -809,22 +817,29 @@ export class Utility {
|
|
|
},
|
|
|
}).then(async () => {
|
|
|
try {
|
|
|
- let videoItem:VideoItem = new VideoItem('',inputPath,inputPath,type,0,'')
|
|
|
const metadata: FFprobeMetadata = JSON.parse(outputJson);
|
|
|
const format = metadata.format;
|
|
|
-
|
|
|
+ console.log(`onecold outputJson:${outputJson}`);
|
|
|
let file = fs.openSync(inputPath, fs.OpenMode.READ_ONLY | fs.OpenMode.CREATE)
|
|
|
console.info('readMetaInfoFFmpeg asset file.path: ', file.path);
|
|
|
- videoItem = new VideoItem(file.name,inputPath,inputPath,type,0,'')
|
|
|
+ let videoItem = new VideoItem(file.name,inputPath,inputPath,type,0,'')
|
|
|
await fs.stat(file.fd).then(async (stat: fs.Stat) => {
|
|
|
|
|
|
// 获取音频流的采样率
|
|
|
- let sampleRate = '';
|
|
|
+ let sampleRate:string|undefined = '';
|
|
|
+ let bits_per_raw_sample:string|undefined = '';
|
|
|
+ let channels:string|undefined = '';
|
|
|
+ let channel_layout:string|undefined = '';
|
|
|
+ let start_time:string|undefined = '';
|
|
|
if (metadata.streams && metadata.streams.length > 0) {
|
|
|
// 查找第一个音频流
|
|
|
const audioStream = metadata.streams.find(stream => StrUtil.isNotEmpty(stream.sample_rate));
|
|
|
- if (audioStream&&audioStream.sample_rate) {
|
|
|
+ if (audioStream) {
|
|
|
sampleRate = audioStream.sample_rate;
|
|
|
+ bits_per_raw_sample = audioStream.bits_per_raw_sample
|
|
|
+ channel_layout = audioStream.channel_layout
|
|
|
+ channels = audioStream.channels
|
|
|
+ start_time = audioStream.start_time
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -859,6 +874,7 @@ export class Utility {
|
|
|
if (!name) {
|
|
|
name = getFileNameWithoutExtension(inputPath);
|
|
|
}
|
|
|
+ let pixelMap:image.PixelMap|undefined|null = undefined
|
|
|
// Create VideoItem
|
|
|
videoItem = new VideoItem(
|
|
|
name,
|
|
|
@@ -886,6 +902,12 @@ export class Utility {
|
|
|
videoItem.lyricContent = tags.LYRICS || tags.lyrics || tags.USLT || tags.UNSYNCEDLYRICS || '';
|
|
|
videoItem.genre = tags.genre||tags.GENRE|| Utility.resourceToString(context, $r('app.string.unknown'));
|
|
|
videoItem.track = tags.track||tags.TRACK|| Utility.resourceToString(context, $r('app.string.unknown'));
|
|
|
+
|
|
|
+ videoItem.bits_per_raw_sample = bits_per_raw_sample ||'1'
|
|
|
+ videoItem.channel_layout = channel_layout || ''
|
|
|
+ videoItem.channels = channels ||'0'
|
|
|
+ videoItem.start_time = start_time || '00:00'
|
|
|
+
|
|
|
videoItem.mimeType = format.format_name
|
|
|
// 检查是否有封面图片流
|
|
|
const hasCover = metadata.streams.some(stream =>
|
|
|
@@ -899,13 +921,22 @@ export class Utility {
|
|
|
try {
|
|
|
if (hasCover) {
|
|
|
//提取封面
|
|
|
- await getFFmpegCover(inputPath, imagePath);
|
|
|
- imagePath = fileUri.getUriFromPath(imagePath)
|
|
|
- videoItem.pixelMapPath = imagePath;
|
|
|
+ let isSuccess:boolean= await getFFmpegCover(inputPath, imagePath);
|
|
|
+
|
|
|
+ if(isSuccess){
|
|
|
+ imagePath = fileUri.getUriFromPath(imagePath)
|
|
|
+ }else{
|
|
|
+ imagePath = ''
|
|
|
+ }
|
|
|
+ videoItem.pixelMapPath = imagePath;
|
|
|
}else if(Utility.isVideoByExtension(inputPath)){
|
|
|
- //提取封面
|
|
|
- await getVideoFFmpegCover(inputPath, imagePath);
|
|
|
- videoItem.mimeType = getFileFormatByPath(inputPath)
|
|
|
+ pixelMap = await Utility.getFetchFrameByTime(inputPath)
|
|
|
+ if(pixelMap!==undefined&&pixelMap!==null){
|
|
|
+ imagePath = await ImageUtil.savePixelMap(pixelMap,context.filesDir,md5Name)
|
|
|
+ imagePath = fileUri.getUriFromPath(imagePath)
|
|
|
+ }else{
|
|
|
+ imagePath = ''
|
|
|
+ }
|
|
|
imagePath = fileUri.getUriFromPath(imagePath)
|
|
|
videoItem.pixelMapPath = imagePath;
|
|
|
}
|
|
|
@@ -1214,19 +1245,7 @@ export class Utility {
|
|
|
}
|
|
|
|
|
|
|
|
|
- static getFileDirName(filePath: string,rootPath:string): string{
|
|
|
- if(filePath===rootPath){
|
|
|
- return '首页'
|
|
|
- }
|
|
|
- let result = FileUtil.getFileName(filePath)
|
|
|
- if(StrUtil.isEmpty(result))
|
|
|
- return ''
|
|
|
-
|
|
|
|
|
|
- if(result.startsWith('.'))
|
|
|
- result = result.replace(/\./g, '')
|
|
|
- return result
|
|
|
- }
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1358,7 +1377,7 @@ export class Utility {
|
|
|
const documentViewPicker = new picker.DocumentViewPicker()
|
|
|
let documentSaveResult = await documentViewPicker.save({ pickerMode: picker.DocumentPickerMode.DOWNLOAD })
|
|
|
let download_path = new fileUri.FileUri(documentSaveResult[0]).path + '/'
|
|
|
- let filePath = download_path + LocalMusic.STR_LOCK_VIDEO+ '/' + VipPage.VIP_FILEPATH
|
|
|
+ let filePath = download_path + STR_LOCK_VIDEO+ '/' + VIP_FILEPATH
|
|
|
let expireDate = Utility.readDataFromFile(filePath)
|
|
|
|
|
|
return expireDate;
|
|
|
@@ -1510,57 +1529,122 @@ function formatDuration(seconds: string, forceHHMMSS: boolean = false): string {
|
|
|
}
|
|
|
|
|
|
|
|
|
-// 定义解析结果的数据结构
|
|
|
+
|
|
|
+/**
|
|
|
+ * 定义解析结果的数据结构
|
|
|
+ */
|
|
|
class MusicInfo {
|
|
|
artist: string = ""; // 艺术家名称
|
|
|
- title: string = ""; // 歌曲名称
|
|
|
+ title: string = ""; // 歌曲名称
|
|
|
isValid: boolean = false; // 格式是否有效
|
|
|
}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 精简版常见中文姓氏(音乐场景优化版)
|
|
|
+ */
|
|
|
+const COMMON_CHINESE_SURNAMES = [
|
|
|
+// 超常见姓氏(覆盖约80%人口)
|
|
|
+ '李','王','张','刘','陈','杨','黄','赵','吴','周',
|
|
|
+ '徐','孙','马','朱','胡','林','郭','何','高','罗',
|
|
|
+ '郑','梁','谢','宋','唐','许','韩','邓','曹','彭',
|
|
|
+ '曾','萧','田','董','潘','袁','于','蔡','余','杜',
|
|
|
+
|
|
|
+ // 音乐行业常见姓氏(歌手高频姓)
|
|
|
+ '汪','苏','薛','谭','华','金','魏','陶','姜','窦',
|
|
|
+ '章','毛','易','方','宋','任','沈','贾','江','孔',
|
|
|
+
|
|
|
+ // 组合/乐队常见字
|
|
|
+ '羽','泉','信','乐','花','飞','龙','风','云','草'
|
|
|
+];
|
|
|
+
|
|
|
/**
|
|
|
- * 解析音乐文件名
|
|
|
- * @param fileName - 待解析的文件名(需包含扩展名)
|
|
|
- * @returns 包含解析结果的MusicInfo对象
|
|
|
+ * 歌手特征关键词(优化版)
|
|
|
*/
|
|
|
+const ARTIST_KEYWORDS = [
|
|
|
+// 中文特征
|
|
|
+ '乐队','组合','乐团','和','&','合唱',' featuring',
|
|
|
+ // 英文特征
|
|
|
+ 'feat','ft','with','vs','presents','presents',
|
|
|
+ // 符号特征
|
|
|
+ '×','X','※','◆','♫'
|
|
|
+];
|
|
|
/**
|
|
|
- * 智能解析音乐文件名(支持多种分隔符和前缀序号)
|
|
|
+ * 智能解析音乐文件名(支持多种分隔符和格式)
|
|
|
* @param fileName - 待解析的完整文件名
|
|
|
* @returns 结构化音乐信息
|
|
|
*/
|
|
|
+/**
|
|
|
+ * 智能解析音乐文件名
|
|
|
+ */
|
|
|
function parseMusicFileName(fileName: string): MusicInfo {
|
|
|
- const result = new MusicInfo();
|
|
|
+ const result: MusicInfo = new MusicInfo();
|
|
|
+ if (!fileName) return result;
|
|
|
|
|
|
- // 1. 预处理:移除首尾空格(保留中间空格)
|
|
|
- const cleanName = fileName.trim();
|
|
|
+ // 预处理
|
|
|
+ const cleanName: string = fileName.trim();
|
|
|
+ const lastDotIndex: number = cleanName.lastIndexOf('.');
|
|
|
+ const baseName: string = lastDotIndex > 0 ?
|
|
|
+ cleanName.substring(0, lastDotIndex).trim() :
|
|
|
+ cleanName;
|
|
|
|
|
|
- // 2. 提取文件扩展名(以最后一个点分隔)
|
|
|
- const lastDotIndex = cleanName.lastIndexOf('.');
|
|
|
- if (lastDotIndex < 0) return result; // 无扩展名
|
|
|
+ // 支持的分隔符(明确定义类型)
|
|
|
+ const separators: string[] = ['-', '-', '—', '~', '~'];
|
|
|
|
|
|
- const baseName = cleanName.substring(0, lastDotIndex).trim();
|
|
|
- const extension = cleanName.substring(lastDotIndex + 1);
|
|
|
+ // 寻找分隔位置
|
|
|
+ let bestSplitIndex: number = -1;
|
|
|
|
|
|
- // 3. 支持多种分隔符(中英文短横线)
|
|
|
- const separators = ['-', '-', '—']; // 半角/全角短横线
|
|
|
- let dashIndex = -1;
|
|
|
-
|
|
|
- // 查找最后一个有效分隔符位置
|
|
|
for (const sep of separators) {
|
|
|
- const index = baseName.lastIndexOf(sep);
|
|
|
- if (index > dashIndex) dashIndex = index;
|
|
|
+ const index: number = baseName.lastIndexOf(sep);
|
|
|
+ if (index > bestSplitIndex) {
|
|
|
+ bestSplitIndex = index;
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- // 4. 核心解析逻辑
|
|
|
- if (dashIndex > 0 && dashIndex < baseName.length - 1) {
|
|
|
- let artistPart = baseName.substring(0, dashIndex).trim();
|
|
|
- result.title = baseName.substring(dashIndex + 1).trim();
|
|
|
+ // 分割字符串
|
|
|
+ if (bestSplitIndex > 0 && bestSplitIndex < baseName.length - 1) {
|
|
|
+ let part1: string = baseName.substring(0, bestSplitIndex).trim();
|
|
|
+ let part2: string = baseName.substring(bestSplitIndex + 1).trim();
|
|
|
+
|
|
|
+ // 处理前缀序号
|
|
|
+ part1 = part1.replace(/^\d+[\s\.\-- —~~]*/, '').trim();
|
|
|
|
|
|
- // 5. 处理前缀序号(如"04 - ")
|
|
|
- const numPrefixRegex = /^\d+\s*[--—]\s*/; // 匹配数字+分隔符组合
|
|
|
- artistPart = artistPart.replace(numPrefixRegex, '').trim();
|
|
|
+ // 判断歌手部分(明确定义返回类型)
|
|
|
+ const identifyArtist = (str: string): boolean => {
|
|
|
+ return COMMON_CHINESE_SURNAMES.some((surname: string) =>
|
|
|
+ str.startsWith(surname) ||
|
|
|
+ new RegExp(`[ ,,、&&]${surname}`).test(str)
|
|
|
+ ) || ARTIST_KEYWORDS.some((keyword: string) =>
|
|
|
+ str.includes(keyword)
|
|
|
+ );
|
|
|
+ };
|
|
|
+
|
|
|
+ // 判断歌手位置
|
|
|
+ const part1IsArtist: boolean = identifyArtist(part1);
|
|
|
+ const part2IsArtist: boolean = identifyArtist(part2);
|
|
|
+
|
|
|
+ if (part1IsArtist && !part2IsArtist) {
|
|
|
+ result.artist = part1;
|
|
|
+ result.title = part2;
|
|
|
+ } else if (part2IsArtist && !part1IsArtist) {
|
|
|
+ result.artist = part2;
|
|
|
+ result.title = part1;
|
|
|
+ } else {
|
|
|
+ result.artist = part1.length <= part2.length ? part1 : part2;
|
|
|
+ result.title = part1.length <= part2.length ? part2 : part1;
|
|
|
+ }
|
|
|
|
|
|
- // 6. 最终有效性验证
|
|
|
- result.artist = artistPart;
|
|
|
- result.isValid = (result.artist.length > 0 && result.title.length > 0);
|
|
|
+ // 后处理
|
|
|
+ result.title = result.title
|
|
|
+ .replace(/(?:\(|()[^))]*(?:)|\))/g, '')
|
|
|
+ .replace(/\s*[—-]\s*(?:Live|Version|Remix|伴奏).*/i, '')
|
|
|
+ .trim();
|
|
|
+
|
|
|
+ // 有效性验证
|
|
|
+ result.isValid = result.artist.length > 0 &&
|
|
|
+ result.title.length > 0;
|
|
|
+ } else {
|
|
|
+ result.title = baseName;
|
|
|
+ result.isValid = result.title.length > 0;
|
|
|
}
|
|
|
|
|
|
return result;
|
|
|
@@ -1578,37 +1662,70 @@ function getFileNameWithoutExtension(filePath: string): string {
|
|
|
* @param inputPath 音乐文件路径
|
|
|
* @returns Promise<void>
|
|
|
*/
|
|
|
-async function getFFmpegCover(inputPath: string, outputPath: string) {
|
|
|
+// async function getFFmpegCover(inputPath: string, outputPath: string) : Promise<boolean>{
|
|
|
+// let commands = ["ffmpeg", "-y","-i", inputPath, "-map", "0:v", "-c:v", "copy", outputPath];
|
|
|
+// FFmpeg.execute(commands, {
|
|
|
+// logCallback: (logLevel: number, logMessage: string) => {
|
|
|
+// console.info(`[FFmpeg LOG] [${logLevel}]${logMessage}`)
|
|
|
+// },
|
|
|
+// progressCallback: (message: string) => {
|
|
|
+// console.info(`[FFmpeg progress]${JSON.stringify(FFProgressMessageParser.parse(message))}`)
|
|
|
+// },
|
|
|
+// })
|
|
|
+// .then(() => {
|
|
|
+// return true
|
|
|
+// console.info("FFmpeg execution succeeded.");
|
|
|
+// })
|
|
|
+// .catch((error: Error) => {
|
|
|
+// return false
|
|
|
+// console.error(`FFmpeg execution failed with error: ${error.message}`);
|
|
|
+// });
|
|
|
+// return ;
|
|
|
+// }
|
|
|
+
|
|
|
+/**
|
|
|
+ * 从音乐文件中提取封面
|
|
|
+ * @param inputPath 音乐文件路径
|
|
|
+ * @param outputPath 封面输出路径
|
|
|
+ * @returns Promise<boolean> 表示操作是否成功
|
|
|
+ */
|
|
|
+async function getFFmpegCover(inputPath: string, outputPath: string): Promise<boolean> {
|
|
|
let commands = ["ffmpeg", "-y","-i", inputPath, "-map", "0:v", "-c:v", "copy", outputPath];
|
|
|
- FFmpeg.execute(commands, {
|
|
|
- logCallback: (logLevel: number, logMessage: string) => {
|
|
|
- console.info(`[FFmpeg LOG] [${logLevel}]${logMessage}`)
|
|
|
- },
|
|
|
- progressCallback: (message: string) => {
|
|
|
- console.info(`[FFmpeg progress]${JSON.stringify(FFProgressMessageParser.parse(message))}`)
|
|
|
- },
|
|
|
- })
|
|
|
- .then(() => {
|
|
|
- console.info("FFmpeg execution succeeded.");
|
|
|
- })
|
|
|
- .catch((error: Error) => {
|
|
|
- console.error(`FFmpeg execution failed with error: ${error.message}`);
|
|
|
+
|
|
|
+ try {
|
|
|
+ await FFmpeg.execute(commands, {
|
|
|
+ logCallback: (logLevel: number, logMessage: string) => {
|
|
|
+ console.info(`[FFmpeg LOG] [${logLevel}] ${logMessage}`);
|
|
|
+ },
|
|
|
+ progressCallback: (message: string) => {
|
|
|
+ console.info(`[FFmpeg progress] ${JSON.stringify(FFProgressMessageParser.parse(message))}`);
|
|
|
+ },
|
|
|
});
|
|
|
+ console.info("FFmpeg execution succeeded.");
|
|
|
+ return true;
|
|
|
+ } catch (error) {
|
|
|
+ console.error(`FFmpeg execution failed with error: ${error instanceof Error ? error.message : String(error)}`);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
}
|
|
|
/**
|
|
|
* 从视频文件中提取封面 提前视频第5帧的封面
|
|
|
* @param inputPath 文件路径
|
|
|
* @returns Promise<void>
|
|
|
*/
|
|
|
-async function getVideoFFmpegCover(inputPath: string, outputPath: string,duration?:string) {
|
|
|
- let durationText = "00:00:05"
|
|
|
- if(duration&&Number(duration) >300000){//如果时间超过5分钟,截10s的缩略图
|
|
|
- durationText = "00:00:10"
|
|
|
- }
|
|
|
- if(duration&&Number(duration) <5000){//如果时间小5s,截0s的缩略图
|
|
|
- durationText = "00:00:01"
|
|
|
- }
|
|
|
- let commands = ["ffmpeg", "-y","-i", inputPath, "-ss", "00:00:03", "-t", "1","-r",'1','-q:v','2','-f','image2', outputPath];
|
|
|
+async function getVideoFFmpegCover(inputPath: string, outputPath: string) {
|
|
|
+
|
|
|
+ // 更可靠的命令,专门提取视频第一帧作为封面
|
|
|
+ const commands = [
|
|
|
+ "ffmpeg",
|
|
|
+ "-y", // 覆盖输出文件(如果存在)
|
|
|
+ "-i", inputPath, // 输入文件
|
|
|
+ "-ss", "00:00:00", // 定位到开始位置
|
|
|
+ "-vframes", "1", // 只提取1帧
|
|
|
+ "-q:v", "2", // 高质量JPEG(1-31,2是最高质量)
|
|
|
+ "-f", "image2", // 强制输出为图像格式
|
|
|
+ outputPath
|
|
|
+ ];
|
|
|
FFmpeg.execute(commands, {
|
|
|
logCallback: (logLevel: number, logMessage: string) => {
|
|
|
console.info(`[FFmpegX LOG] [${logLevel}]${logMessage}`)
|