|
|
@@ -24,6 +24,66 @@ 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';
|
|
|
+interface FFMpegTags {
|
|
|
+ album?: string;
|
|
|
+ artist?: string;
|
|
|
+ title?: string;
|
|
|
+ track?: string;
|
|
|
+ TYER?: string;
|
|
|
+ date?: string;
|
|
|
+ LYRICS?: string;
|
|
|
+ lyrics?: string; // 小写变体
|
|
|
+ USLT?: string; // ID3v2同步歌词
|
|
|
+ UNSYNCEDLYRICS?: string; // ID3v2非同步歌词
|
|
|
+ // Add any other tag properties you expect
|
|
|
+}
|
|
|
+
|
|
|
+interface FFprobeFormat {
|
|
|
+ filename: string;
|
|
|
+ nb_streams: number;
|
|
|
+ nb_programs: number;
|
|
|
+ format_name: string;
|
|
|
+ format_long_name: string;
|
|
|
+ duration: string;
|
|
|
+ size: string;
|
|
|
+ bit_rate: string;
|
|
|
+ probe_score: number;
|
|
|
+ tags?: FFMpegTags;
|
|
|
+}
|
|
|
+
|
|
|
+interface FFprobeStream {
|
|
|
+ // Define stream properties as needed
|
|
|
+ codec_type?: string; // 流类型,如"audio"、"video"
|
|
|
+ sample_rate?: string; // 采样率
|
|
|
+ bit_rate?: string; // 比特率
|
|
|
+ disposition?: StreamDisposition; // 添加disposition属性
|
|
|
+}
|
|
|
+
|
|
|
+interface StreamDisposition {
|
|
|
+ default?: number;
|
|
|
+ dub?: number;
|
|
|
+ original?: number;
|
|
|
+ comment?: number;
|
|
|
+ lyrics?: number;
|
|
|
+ karaoke?: number;
|
|
|
+ forced?: number;
|
|
|
+ hearing_impaired?: number;
|
|
|
+ visual_impaired?: number;
|
|
|
+ clean_effects?: number;
|
|
|
+ attached_pic?: number; // 添加封面图片标识
|
|
|
+ timed_thumbnails?: number;
|
|
|
+ captions?: number;
|
|
|
+ descriptions?: number;
|
|
|
+ metadata?: number;
|
|
|
+ dependent?: number;
|
|
|
+ still_image?: number;
|
|
|
+}
|
|
|
+
|
|
|
+interface FFprobeMetadata {
|
|
|
+ streams: FFprobeStream[];
|
|
|
+ format: FFprobeFormat;
|
|
|
+}
|
|
|
|
|
|
export class Utility {
|
|
|
|
|
|
@@ -608,6 +668,17 @@ export class Utility {
|
|
|
|
|
|
//获取音乐资源的属性值,
|
|
|
static async uriGetMusicAssetsFromFile(context:Context,uri:string,type:number,isLoadPixelMap?:boolean): Promise<VideoItem> {
|
|
|
+ //华为官方不支持的格式 不支持内嵌歌词Dsf,aif,aiff,wav 不支持内嵌封面dsf,aif,aiff
|
|
|
+ if(StrUtil.isNotEmpty(uri)){
|
|
|
+ if(uri.toLowerCase().endsWith('.dsf')
|
|
|
+ ||uri.toLowerCase().endsWith('.aif')
|
|
|
+ // ||uri.toLowerCase().endsWith('.wav')
|
|
|
+ ||uri.toLowerCase().endsWith('.aiff')){
|
|
|
+ return Utility.readMetaInfoFFmpeg(context,uri,type)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
let item:VideoItem = new VideoItem('',uri,uri,type,0,'')
|
|
|
try {
|
|
|
console.info('asset file.uri: ', uri);
|
|
|
@@ -751,6 +822,8 @@ export class Utility {
|
|
|
item.isFav = 0;
|
|
|
item.playCount = 0;
|
|
|
item.pyStr = pinyin4js.getShortPinyin(musicName)
|
|
|
+
|
|
|
+ item.lyricContent = await extractLyricsContent(uri)
|
|
|
console.info('onecold pyStr = '+pinyin4js.getShortPinyin(musicName));
|
|
|
})
|
|
|
} catch (error) {
|
|
|
@@ -759,10 +832,122 @@ export class Utility {
|
|
|
|
|
|
return item
|
|
|
|
|
|
-
|
|
|
}
|
|
|
|
|
|
|
|
|
+ static async readMetaInfoFFmpeg(context:Context,inputPath: string,type:number): Promise<VideoItem> {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ let commands = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", inputPath];
|
|
|
+ let outputJson = "";
|
|
|
+
|
|
|
+ FFmpeg.execute(commands, {
|
|
|
+ logCallback: (logLevel: number, logMessage: string) => console.log(`[${logLevel}]${logMessage}`),
|
|
|
+ outputCallback: (message: string) => {
|
|
|
+ outputJson += message;
|
|
|
+ },
|
|
|
+ }).then(async () => {
|
|
|
+ try {
|
|
|
+ let videoItem:VideoItem = new VideoItem('',inputPath,inputPath,type,0,'')
|
|
|
+ const metadata: FFprobeMetadata = JSON.parse(outputJson);
|
|
|
+ const format = metadata.format;
|
|
|
+
|
|
|
+ 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,'')
|
|
|
+ await fs.stat(file.fd).then(async (stat: fs.Stat) => {
|
|
|
+
|
|
|
+ // 获取音频流的采样率
|
|
|
+ let sampleRate = '';
|
|
|
+ if (metadata.streams && metadata.streams.length > 0) {
|
|
|
+ // 查找第一个音频流
|
|
|
+ const audioStream = metadata.streams.find(stream => StrUtil.isNotEmpty(stream.sample_rate));
|
|
|
+ if (audioStream&&audioStream.sample_rate) {
|
|
|
+ sampleRate = audioStream.sample_rate;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ let videoSize = stat.size
|
|
|
+ let fileSize = Utility.formatFSize(videoSize)
|
|
|
+ //按照添加时间
|
|
|
+ let addTime = Utility.getFormatDateStr(new Date(),'yyyy-MM-dd HH:mm');
|
|
|
+ // Extract artist and title from tags
|
|
|
+ const tags = format.tags || {};
|
|
|
+ const artist = tags.artist || '';
|
|
|
+ const title = tags.title || '';
|
|
|
+ const album = tags.album || '';
|
|
|
+
|
|
|
+ let name: string = title;
|
|
|
+ if (!name) {
|
|
|
+ name = getFileNameWithoutExtension(inputPath);
|
|
|
+ }
|
|
|
+ // Create VideoItem
|
|
|
+ videoItem = new VideoItem(
|
|
|
+ name,
|
|
|
+ inputPath, // id can be generated or left empty
|
|
|
+ inputPath,
|
|
|
+ type, // assuming it's local
|
|
|
+ videoSize,
|
|
|
+ addTime // convert to ISO string
|
|
|
+ );
|
|
|
+
|
|
|
+ // Set additional properties from format metadata
|
|
|
+ videoItem.artist = artist;
|
|
|
+ videoItem.album = album;
|
|
|
+ videoItem.mimeType = format.format_name
|
|
|
+ videoItem.sampleRate = sampleRate
|
|
|
+ videoItem.fileName = FileUtil.getFileName(inputPath);
|
|
|
+ if(format.duration)
|
|
|
+ videoItem.duration = convertSecondsToTime(format.duration.toString())
|
|
|
+ videoItem.size = fileSize;
|
|
|
+ videoItem.bit_rate = format.bit_rate;
|
|
|
+ videoItem.probe_score = format.probe_score;
|
|
|
+ videoItem.nb_streams = format.nb_streams;
|
|
|
+ videoItem.nb_programs = format.nb_programs;
|
|
|
+ videoItem.year = tags.TYER || tags.date || ''; // try different tag names for year
|
|
|
+ videoItem.lyricContent = tags.LYRICS || tags.lyrics || tags.USLT || tags.UNSYNCEDLYRICS || '';
|
|
|
+
|
|
|
+ // 检查是否有封面图片流
|
|
|
+ const hasCover = metadata.streams.some(stream =>
|
|
|
+ stream.disposition?.attached_pic === 1
|
|
|
+ );
|
|
|
+ console.info('readMetaInfoFFmpeg asset hasCover: ', hasCover);
|
|
|
+ // 如果有封面图片,则提取
|
|
|
+ if (hasCover) {
|
|
|
+
|
|
|
+ // try {
|
|
|
+ // let md5Name = await MD5.digestSync(inputPath)
|
|
|
+ // // const imageName = `${md5Name}.jpg`;
|
|
|
+ // const imagePath = `${context.filesDir}${FileUtil.separator}${md5Name}`;
|
|
|
+ // console.info('readMetaInfoFFmpeg asset imagePath: ', imagePath);
|
|
|
+ // // 提取封面图片
|
|
|
+ // await extractCoverImage(inputPath, imagePath);
|
|
|
+ //
|
|
|
+ // // 检查图片是否生成成功
|
|
|
+ // if (fs.accessSync(imagePath)) {
|
|
|
+ // videoItem.pixelMapPath = imagePath;
|
|
|
+ //
|
|
|
+ // }
|
|
|
+ // } catch (error) {
|
|
|
+ // console.warn(' 提取封面图片失败:', error.message);
|
|
|
+ // }
|
|
|
+ }
|
|
|
+
|
|
|
+ console.info('Successfully parsed metadata:', videoItem);
|
|
|
+ resolve(videoItem);
|
|
|
+ })
|
|
|
+
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error('Failed to parse metadata:', error);
|
|
|
+ reject(new Error('Failed to parse metadata: ' + error.message));
|
|
|
+ }
|
|
|
+ }).catch((error: Error) => {
|
|
|
+ console.error(`Execution failed with error: ${error.message}`);
|
|
|
+ reject(error);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
|
|
|
private completionNum(num: number): string | number {
|
|
|
if (num < 10) {
|
|
|
@@ -1371,4 +1556,76 @@ function parseMusicFileName(fileName: string): MusicInfo {
|
|
|
}
|
|
|
|
|
|
return result;
|
|
|
+}
|
|
|
+
|
|
|
+function getFileNameWithoutExtension(filePath: string): string {
|
|
|
+ const fileName = filePath.split('/').pop() || '';
|
|
|
+ const lastDotIndex = fileName.lastIndexOf('.');
|
|
|
+ return lastDotIndex > 0 ? fileName.substring(0, lastDotIndex) : fileName;
|
|
|
+}
|
|
|
+
|
|
|
+async function extractCoverImage(inputPath: string, outputPath: string): Promise<void> {
|
|
|
+ const commands = [
|
|
|
+ 'ffmpeg',
|
|
|
+ '-i', inputPath,
|
|
|
+ '-an', // 禁用音频
|
|
|
+ '-vcodec', 'copy', // 直接复制视频流
|
|
|
+ '-f', 'image2', // 强制输出为图片
|
|
|
+ '-y', // 覆盖输出文件
|
|
|
+ outputPath
|
|
|
+ ];
|
|
|
+
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ FFmpeg.execute(commands, {
|
|
|
+ logCallback: (logLevel: number, logMessage: string) => {
|
|
|
+ console.log(`[${logLevel}]${logMessage}`);
|
|
|
+ },
|
|
|
+ outputCallback: (message: string) => {
|
|
|
+ console.log(`FFmpeg output: ${message}`);
|
|
|
+ },
|
|
|
+ }).then(() => resolve())
|
|
|
+ .catch((error: BusinessError) => reject(error));
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 从音乐文件中提取歌词内容
|
|
|
+ * @param inputPath 音乐文件路径
|
|
|
+ * @returns Promise<string> 直接返回歌词内容,若无歌词则返回空字符串
|
|
|
+ */
|
|
|
+async function extractLyricsContent(inputPath: string): Promise<string> {
|
|
|
+ return new Promise(async (resolve, reject) => {
|
|
|
+ try {
|
|
|
+ // 1. 使用ffprobe获取元数据
|
|
|
+ const commands = [
|
|
|
+ 'ffprobe',
|
|
|
+ '-v', 'quiet',
|
|
|
+ '-print_format', 'json',
|
|
|
+ '-show_format',
|
|
|
+ inputPath
|
|
|
+ ];
|
|
|
+
|
|
|
+ let outputJson = '';
|
|
|
+ await FFmpeg.execute(commands, {
|
|
|
+ outputCallback: (message: string) => outputJson += message,
|
|
|
+ });
|
|
|
+
|
|
|
+ // 2. 解析歌词标签
|
|
|
+ const metadata:FFprobeMetadata = JSON.parse(outputJson);
|
|
|
+ const tags = metadata.format?.tags || {};
|
|
|
+
|
|
|
+ // 3. 从常见标签中查找歌词(优先级顺序)
|
|
|
+ const lyricContent =
|
|
|
+ tags.LYRICS || // 标准标签
|
|
|
+ tags.lyrics || // 小写变体
|
|
|
+ tags.USLT || // ID3v2标签
|
|
|
+ tags.UNSYNCEDLYRICS ||
|
|
|
+ '';
|
|
|
+
|
|
|
+ resolve(lyricContent.trim());
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ reject(`解析失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
|
+ }
|
|
|
+ });
|
|
|
}
|