HarmonyOS媒体元数据开发实战
核心要点:元数据是媒体文件的「身份证」——它告诉你这首歌叫什么名字、谁唱的、哪张专辑的、封面长什么样。掌握 AVMetadata 的设置与读取、ID3 标签解析、封面图提取和元数据编辑,是构建专业级音乐/视频播放器的必备技能。
| 项目 | 说明 |
|---|---|
| 核心模块 | @ohos.multimedia.avsession / @ohos.multimedia.media |
一、背景与动机
你有没有好奇过,音乐播放器是怎么知道一首歌的标题、艺术家、专辑名的?打开一个 .mp3 文件,里面明明只有音频数据,播放器怎么就能显示封面图?
答案藏在「元数据」里。
元数据(Metadata)就是「关于数据的数据」。对于音乐文件来说,元数据包括歌曲标题、艺术家、专辑、年份、流派、封面图等信息。这些信息被嵌入在音频文件内部,遵循特定的格式标准——最常见的就是 ID3 标签。
ID3 标签就像贴在文件上的「标签纸」,它和音频数据共存于同一个文件中。播放器在打开文件时,会先读取 ID3 标签,提取元数据,然后展示给用户。
在 HarmonyOS 中,元数据有两个层面的使用:
- AVSession 层面——通过
AVMetadata向系统媒体控制中心发布当前播放信息 - 文件层面——从音频/视频文件中提取 ID3 标签等嵌入式元数据
这两者不是一回事,但密切相关。通常的流程是:从文件提取元数据 → 构造 AVMetadata → 发布到 AVSession。
二、核心原理
2.1 元数据的两个层面
flowchart TB
classDef primary fill:#1890ff,stroke:#096dd9,color:#fff
classDef warning fill:#fa8c16,stroke:#d48806,color:#fff
classDef error fill:#f5222d,stroke:#cf1322,color:#fff
classDef info fill:#13c2c2,stroke:#006d75,color:#fff
classDef purple fill:#722ed1,stroke:#531dab,color:#fff
subgraph 文件层面
A[MP3文件]:::primary
B[FLAC文件]:::primary
C[MP4文件]:::primary
end
subgraph 元数据提取
D[ID3v1/v2 解析器]:::warning
E[Vorbis Comment 解析器]:::warning
F[MP4 Box 解析器]:::warning
end
subgraph 元数据结构
G[标题 title]:::info
H[艺术家 artist]:::info
I[专辑 album]:::info
J[封面图 mediaImage]:::info
K[时长 duration]:::info
L[年份 year]:::info
M[流派 genre]:::info
end
subgraph AVSession层面
N[AVMetadata]:::purple
O[锁屏显示]:::purple
P[通知栏显示]:::purple
end
A --> D
B --> E
C --> F
D --> G & H & I & J & K & L & M
E --> G & H & I & J & K & L & M
F --> G & H & I & J & K & L & M
G & H & I & J & K --> N
N --> O & P
2.2 ID3 标签格式
ID3 是 MP3 文件最常用的元数据格式,有两个主要版本:
| 版本 | 位置 | 大小限制 | 特点 |
|---|---|---|---|
| ID3v1 | 文件末尾 | 固定 128 字节 | 字段长度受限(标题最多 30 字符),不支持封面 |
| ID3v2 | 文件开头 | 可变长度 | 支持长文本、封面图、自定义字段,是主流格式 |
ID3v2 的帧结构:
| 帧ID | 含义 | 示例 |
|---|---|---|
| TIT2 | 标题 | “夜曲” |
| TPE1 | 艺术家 | “周杰伦” |
| TALB | 专辑 | “十一月的萧邦” |
| TYER | 年份 | “2005” |
| TCON | 流派 | “Pop” |
| APIC | 封面图 | 二进制图片数据 |
| TRCK | 音轨号 | “01” |
2.3 AVMetadata 字段一览
AVMetadata 是 AVSession 使用的元数据结构,字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| assetId | string | 媒体资源唯一标识 |
| title | string | 标题 |
| artist | string | 艺术家 |
| album | string | 专辑 |
| writer | string | 作词/作曲 |
| composer | string | 作曲家 |
| duration | number | 时长(毫秒) |
| mediaType | string | 媒体类型(AUDIO/VIDEO) |
| mediaImage | AVMediaImage | 封面图(URI 或 PixelMap) |
| subtitle | string | 副标题 |
| description | string | 描述 |
| lyric | string | 歌词 |
| previousAssetId | string | 上一曲资源 ID |
| nextAssetId | string | 下一曲资源 ID |
三、代码实战
3.1 构建与设置 AVMetadata
这是最基础的用法——构造 AVMetadata 对象,设置到 AVSession 上,让系统媒体控制中心显示播放信息。
import { avsession } from '@kit.MultimediaKit';
import { image } from '@kit.ImageKit';
import { BusinessError } from '@kit.BasicServicesKit';
/**
* AVMetadata 构建器
* 封装元数据的构建逻辑,支持链式调用
*/
class AVMetadataBuilder {
private metadata: avsession.AVMetadata = {
assetId: '',
};
/** 设置资源 ID(必须) */
setAssetId(id: string): AVMetadataBuilder {
this.metadata.assetId = id;
return this;
}
/** 设置标题 */
setTitle(title: string): AVMetadataBuilder {
this.metadata.title = title;
return this;
}
/** 设置艺术家 */
setArtist(artist: string): AVMetadataBuilder {
this.metadata.artist = artist;
return this;
}
/** 设置专辑 */
setAlbum(album: string): AVMetadataBuilder {
this.metadata.album = album;
return this;
}
/** 设置作曲家 */
setComposer(composer: string): AVMetadataBuilder {
this.metadata.composer = composer;
return this;
}
/** 设置作词 */
setWriter(writer: string): AVMetadataBuilder {
this.metadata.writer = writer;
return this;
}
/** 设置时长(毫秒) */
setDuration(durationMs: number): AVMetadataBuilder {
this.metadata.duration = durationMs;
return this;
}
/** 设置媒体类型 */
setMediaType(type: 'AUDIO' | 'VIDEO'): AVMetadataBuilder {
this.metadata.mediaType = type;
return this;
}
/** 设置封面图(URI 方式) */
setCoverUri(uri: string): AVMetadataBuilder {
this.metadata.mediaImage = avsession.createAVMediaImage(uri);
return this;
}
/** 设置封面图(PixelMap 方式) */
setCoverPixelMap(pixelMap: image.PixelMap): AVMetadataBuilder {
this.metadata.mediaImage = avsession.createAVMediaImage(pixelMap);
return this;
}
/** 设置副标题 */
setSubtitle(subtitle: string): AVMetadataBuilder {
this.metadata.subtitle = subtitle;
return this;
}
/** 设置描述 */
setDescription(description: string): AVMetadataBuilder {
this.metadata.description = description;
return this;
}
/** 设置歌词 */
setLyric(lyric: string): AVMetadataBuilder {
this.metadata.lyric = lyric;
return this;
}
/** 设置上一曲/下一曲 ID */
setNavigation(prevId: string, nextId: string): AVMetadataBuilder {
this.metadata.previousAssetId = prevId;
this.metadata.nextAssetId = nextId;
return this;
}
/** 构建元数据对象 */
build(): avsession.AVMetadata {
// 验证必填字段
if (!this.metadata.assetId) {
console.warn('[MetadataBuilder] assetId 为空,可能导致系统无法正确识别');
}
return this.metadata;
}
}
// ========== 使用示例 ==========
/**
* 使用构建器创建完整的元数据
*/
async function createSongMetadata(songInfo: {
id: string;
title: string;
artist: string;
album: string;
duration: number;
coverUrl: string;
prevId?: string;
nextId?: string;
}): Promise<avsession.AVMetadata> {
const builder = new AVMetadataBuilder();
const metadata = builder
.setAssetId(songInfo.id)
.setTitle(songInfo.title)
.setArtist(songInfo.artist)
.setAlbum(songInfo.album)
.setDuration(songInfo.duration)
.setMediaType('AUDIO')
.setCoverUri(songInfo.coverUrl)
.setNavigation(songInfo.prevId ?? '', songInfo.nextId ?? '')
.build();
return metadata;
}
/**
* 将元数据设置到 AVSession
*/
async function applyMetadataToSession(
session: avsession.AVSession,
metadata: avsession.AVMetadata
): Promise<void> {
try {
await session.setAVMetadata(metadata);
console.info(`[Metadata] 元数据已设置: ${metadata.title} - ${metadata.artist}`);
} catch (err) {
const error = err as BusinessError;
console.error(`[Metadata] 设置元数据失败: ${error.message}`);
}
}
3.2 元数据提取与 ID3 标签解析
从音频文件中提取元数据是播放器的核心能力。HarmonyOS 提供了 media.createAVMetadataExtractor 来提取文件中的元数据。
import { media } from '@kit.MediaKit';
import { image } from '@kit.ImageKit';
import { fileIo } from '@kit.CoreFileKit';
import { BusinessError } from '@kit.BasicServicesKit';
/**
* 文件元数据提取结果
*/
interface ExtractedMetadata {
title: string; // 标题
artist: string; // 艺术家
album: string; // 专辑
composer: string; // 作曲家
writer: string; // 作词
genre: string; // 流派
year: string; // 年份
trackNumber: string; // 音轨号
duration: number; // 时长(毫秒)
coverPixelMap: image.PixelMap | null; // 封面图
mimeType: string; // MIME 类型
sampleRate: number; // 采样率
channels: number; // 声道数
bitRate: number; // 比特率
}
/**
* 媒体文件元数据提取器
* 从音频/视频文件中提取 ID3 标签等元数据
*/
class MediaMetadataExtractor {
/**
* 从本地文件提取元数据
* @param filePath 文件路径(沙箱路径或 URI)
*/
async extractFromFile(filePath: string): Promise<ExtractedMetadata | null> {
try {
// 第一步:创建元数据提取器
const extractor = await media.createAVMetadataExtractor();
// 第二步:设置数据源(文件路径)
extractor.fdSrc = await this.getFdSrc(filePath);
// 第三步:提取元数据
const metadata = await extractor.resolveMetadata();
// 第四步:提取封面图
const coverPixelMap = await this.extractCoverImage(extractor);
// 第五步:释放提取器
await extractor.release();
// 第六步:组装结果
const result = this.buildExtractedMetadata(metadata, coverPixelMap);
console.info(`[Extractor] 提取成功: ${result.title} - ${result.artist}`);
return result;
} catch (err) {
const error = err as BusinessError;
console.error(`[Extractor] 提取元数据失败: ${error.message}`);
return null;
}
}
/**
* 获取文件描述符源
*/
private async getFdSrc(filePath: string): Promise<media.AVMetadataExtractorFdSrc> {
const file = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY);
const stat = fileIo.statSync(filePath);
return {
fd: file.fd,
offset: 0,
length: stat.size,
};
}
/**
* 提取封面图
*/
private async extractCoverImage(extractor: media.AVMetadataExtractor): Promise<image.PixelMap | null> {
try {
// 从提取器中获取封面图数据
const imageData = await extractor.fetchAlbumCover();
if (!imageData || imageData.byteLength === 0) {
console.info('[Extractor] 文件中没有封面图');
return null;
}
// 将二进制数据解码为 PixelMap
const imageSource = image.createImageSource(imageData);
const pixelMap = await imageSource.createPixelMap();
console.info('[Extractor] 封面图提取成功');
return pixelMap;
} catch (err) {
console.warn('[Extractor] 封面图提取失败(文件可能没有嵌入封面)');
return null;
}
}
/**
* 组装提取结果
*/
private buildExtractedMetadata(
rawMetadata: Record<string, string>,
coverPixelMap: image.PixelMap | null
): ExtractedMetadata {
return {
title: rawMetadata[media.AVMetadataKey.METADATA_KEY_TITLE] ?? '未知标题',
artist: rawMetadata[media.AVMetadataKey.METADATA_KEY_ARTIST] ?? '未知艺术家',
album: rawMetadata[media.AVMetadataKey.METADATA_KEY_ALBUM] ?? '未知专辑',
composer: rawMetadata[media.AVMetadataKey.METADATA_KEY_COMPOSER] ?? '',
writer: rawMetadata[media.AVMetadataKey.METADATA_KEY_LYRICIST] ?? '',
genre: rawMetadata[media.AVMetadataKey.METADATA_KEY_GENRE] ?? '',
year: rawMetadata[media.AVMetadataKey.METADATA_KEY_DATE] ?? '',
trackNumber: rawMetadata[media.AVMetadataKey.METADATA_KEY_TRACK_NUMBER] ?? '',
duration: parseInt(rawMetadata[media.AVMetadataKey.METADATA_KEY_DURATION] ?? '0'),
coverPixelMap,
mimeType: rawMetadata[media.AVMetadataKey.METADATA_KEY_MIME_TYPE] ?? '',
sampleRate: parseInt(rawMetadata[media.AVMetadataKey.METADATA_KEY_SAMPLE_RATE] ?? '0'),
channels: parseInt(rawMetadata[media.AVMetadataKey.METADATA_KEY_CHANNEL_COUNT] ?? '0'),
bitRate: parseInt(rawMetadata[media.AVMetadataKey.METADATA_KEY_BIT_RATE] ?? '0'),
};
}
}
// ========== 使用示例 ==========
const extractor = new MediaMetadataExtractor();
async function loadSongMetadata(filePath: string): Promise<void> {
const metadata = await extractor.extractFromFile(filePath);
if (!metadata) return;
console.info(`[Song] ${metadata.title}`);
console.info(`[Song] ${metadata.artist} - ${metadata.album}`);
console.info(`[Song] 时长: ${metadata.duration}ms, 采样率: ${metadata.sampleRate}Hz`);
console.info(`[Song] 封面: ${metadata.coverPixelMap ? '有' : '无'}`);
}
3.3 封面图处理与元数据编辑
封面图是元数据中最复杂的部分——它可能是嵌入在文件中的二进制数据,也可能是网络 URL,还可能是动态生成的。下面展示封面图的各种处理方式和元数据编辑流程。
import { avsession } from '@kit.MultimediaKit';
import { media } from '@kit.MediaKit';
import { image } from '@kit.ImageKit';
import { fileIo } from '@kit.CoreFileKit';
import { BusinessError } from '@kit.BasicServicesKit';
/**
* 封面图管理器
* 处理封面图的各种来源和格式转换
*/
class CoverImageManager {
/**
* 方式一:从文件提取封面图,转为 AVMediaImage
* 适用于本地音乐文件的封面提取
*/
async extractCoverAsMediaImage(filePath: string): Promise<avsession.AVMediaImage | null> {
try {
const extractor = await media.createAVMetadataExtractor();
const file = fileIo.openSync(filePath, fileIo.OpenMode.READ_ONLY);
const stat = fileIo.statSync(filePath);
extractor.fdSrc = {
fd: file.fd,
offset: 0,
length: stat.size,
};
// 提取封面二进制数据
const imageData = await extractor.fetchAlbumCover();
await extractor.release();
fileIo.closeSync(file.fd);
if (!imageData || imageData.byteLength === 0) {
return null;
}
// 解码为 PixelMap
const imageSource = image.createImageSource(imageData);
const pixelMap = await imageSource.createPixelMap();
// 转为 AVMediaImage
return avsession.createAVMediaImage(pixelMap);
} catch (err) {
console.warn('[Cover] 提取封面失败');
return null;
}
}
/**
* 方式二:从网络 URL 加载封面图
* 适用于在线音乐播放
*/
async loadCoverFromUrl(url: string): Promise<avsession.AVMediaImage> {
// 直接使用 URL 创建 AVMediaImage
// 系统会自动处理网络加载
return avsession.createAVMediaImage(url);
}
/**
* 方式三:从应用资源加载封面图
* 适用于默认封面或本地缓存
*/
async loadCoverFromResource(resourcePath: string): Promise<avsession.AVMediaImage> {
// 使用 $r 语法或 rawfile 路径
return avsession.createAVMediaImage(resourcePath);
}
/**
* 方式四:生成纯色默认封面
* 当文件没有封面时,生成一个占位图
*/
async generateDefaultCover(title: string): Promise<avsession.AVMediaImage> {
// 创建一个 200x200 的 PixelMap
const pixelMapOptions: image.PixelMapOptions = {
size: { width: 200, height: 200 },
pixelFormat: image.PixelFormat.RGBA_8888,
editable: true,
};
const pixelMap = await image.createPixelMap(null, pixelMapOptions);
// 读取和写入像素数据来填充颜色
const buffer = new ArrayBuffer(200 * 200 * 4);
const data = new Uint8Array(buffer);
// 填充渐变色
for (let y = 0; y < 200; y++) {
for (let x = 0; x < 200; x++) {
const offset = (y * 200 + x) * 4;
data[offset] = 30 + Math.floor(x * 0.5); // R
data[offset + 1] = 80 + Math.floor(y * 0.3); // G
data[offset + 2] = 180; // B
data[offset + 3] = 255; // A
}
}
await pixelMap.writeBufferToPixels(buffer);
return avsession.createAVMediaImage(pixelMap);
}
/**
* 压缩封面图
* 大尺寸封面图可能导致锁屏渲染卡顿,建议压缩到 200KB 以内
*/
async compressCover(pixelMap: image.PixelMap, maxSizeKB: number = 200): Promise<image.PixelMap> {
// 获取原始尺寸
const info = await pixelMap.getImageInfo();
let width = info.size.width;
let height = info.size.height;
// 如果尺寸过大,按比例缩小
const maxDimension = 600;
if (width > maxDimension || height > maxDimension) {
const scale = maxDimension / Math.max(width, height);
width = Math.floor(width * scale);
height = Math.floor(height * scale);
await pixelMap.scale(scale, scale);
}
console.info(`[Cover] 压缩后尺寸: ${width}x${height}`);
return pixelMap;
}
}
/**
* 元数据编辑器
* 修改音频文件的 ID3 标签等元数据
*/
class MetadataEditor {
/**
* 将提取的元数据转换为 AVMetadata
* 用于 AVSession 发布
*/
convertToAVMetadata(
extracted: ExtractedMetadata,
assetId: string,
prevId?: string,
nextId?: string
): avsession.AVMetadata {
const metadata: avsession.AVMetadata = {
assetId: assetId,
title: extracted.title,
artist: extracted.artist,
album: extracted.album,
composer: extracted.composer,
writer: extracted.writer,
duration: extracted.duration,
mediaType: 'AUDIO',
previousAssetId: prevId ?? '',
nextAssetId: nextId ?? '',
};
// 设置封面图
if (extracted.coverPixelMap) {
metadata.mediaImage = avsession.createAVMediaImage(extracted.coverPixelMap);
}
return metadata;
}
/**
* 批量转换播放列表的元数据
* 为每首歌设置上一曲/下一曲的导航信息
*/
convertPlaylistToAVMetadataList(
songs: ExtractedMetadata[]
): avsession.AVMetadata[] {
return songs.map((song, index) => {
const prevId = index > 0 ? `track_${index - 1}` : '';
const nextId = index < songs.length - 1 ? `track_${index + 1}` : '';
return this.convertToAVMetadata(song, `track_${index}`, prevId, nextId);
});
}
/**
* 合并在线元数据
* 当本地文件元数据不完整时,用在线数据补充
*/
mergeWithOnlineMetadata(
local: avsession.AVMetadata,
online: {
title?: string;
artist?: string;
album?: string;
coverUrl?: string;
lyric?: string;
}
): avsession.AVMetadata {
// 在线数据优先,本地数据兜底
const merged: avsession.AVMetadata = {
...local,
title: online.title ?? local.title,
artist: online.artist ?? local.artist,
album: online.album ?? local.album,
};
// 在线封面
if (online.coverUrl) {
merged.mediaImage = avsession.createAVMediaImage(online.coverUrl);
}
// 在线歌词
if (online.lyric) {
merged.lyric = online.lyric;
}
return merged;
}
/**
* 格式化时长显示
*/
formatDuration(durationMs: number): string {
const totalSeconds = Math.floor(durationMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
/**
* 格式化文件大小显示
*/
formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
}
// ========== 完整的播放器元数据管理流程 ==========
/**
* 播放器元数据管理器
* 整合提取、转换、发布的完整流程
*/
class PlayerMetadataManager {
private session: avsession.AVSession | null = null;
private extractor: MediaMetadataExtractor;
private coverManager: CoverImageManager;
private editor: MetadataEditor;
// 当前播放列表的元数据缓存
private playlistMetadata: Map<string, ExtractedMetadata> = new Map();
constructor(session: avsession.AVSession) {
this.session = session;
this.extractor = new MediaMetadataExtractor();
this.coverManager = new CoverImageManager();
this.editor = new MetadataEditor();
}
/**
* 加载并发布歌曲元数据
* 完整流程:提取 → 转换 → 发布
*/
async loadAndPublishSong(
filePath: string,
assetId: string,
prevId?: string,
nextId?: string
): Promise<void> {
if (!this.session) return;
try {
// 第一步:从文件提取元数据
const extracted = await this.extractor.extractFromFile(filePath);
if (!extracted) {
console.warn('[PlayerMeta] 提取元数据失败,使用默认值');
await this.publishDefaultMetadata(assetId);
return;
}
// 缓存元数据
this.playlistMetadata.set(assetId, extracted);
// 第二步:转换为 AVMetadata
let avMetadata = this.editor.convertToAVMetadata(extracted, assetId, prevId, nextId);
// 第三步:如果没有封面,生成默认封面
if (!avMetadata.mediaImage) {
const defaultCover = await this.coverManager.generateDefaultCover(extracted.title);
avMetadata.mediaImage = defaultCover;
}
// 第四步:发布到 AVSession
await this.session.setAVMetadata(avMetadata);
console.info(`[PlayerMeta] 元数据已发布: ${extracted.title} - ${extracted.artist}`);
} catch (err) {
const error = err as BusinessError;
console.error(`[PlayerMeta] 加载发布失败: ${error.message}`);
}
}
/**
* 发布默认元数据(当提取失败时使用)
*/
private async publishDefaultMetadata(assetId: string): Promise<void> {
if (!this.session) return;
const defaultMetadata: avsession.AVMetadata = {
assetId: assetId,
title: '未知歌曲',
artist: '未知艺术家',
album: '未知专辑',
mediaType: 'AUDIO',
};
const defaultCover = await this.coverManager.generateDefaultCover('未知');
defaultMetadata.mediaImage = defaultCover;
await this.session.setAVMetadata(defaultMetadata);
}
/**
* 预加载播放列表中所有歌曲的元数据
* 在后台线程中提前提取,避免切歌时卡顿
*/
async preloadPlaylist(filePaths: Map<string, string>): Promise<void> {
console.info(`[PlayerMeta] 开始预加载 ${filePaths.size} 首歌曲的元数据`);
for (const [assetId, filePath] of filePaths) {
try {
const extracted = await this.extractor.extractFromFile(filePath);
if (extracted) {
this.playlistMetadata.set(assetId, extracted);
}
} catch (err) {
console.warn(`[PlayerMeta] 预加载失败: ${assetId}`);
}
}
console.info(`[PlayerMeta] 预加载完成: ${this.playlistMetadata.size}/${filePaths.size}`);
}
}
// ========== 提取结果类型(复用前面的定义) ==========
interface ExtractedMetadata {
title: string;
artist: string;
album: string;
composer: string;
writer: string;
genre: string;
year: string;
trackNumber: string;
duration: number;
coverPixelMap: image.PixelMap | null;
mimeType: string;
sampleRate: number;
channels: number;
bitRate: number;
}
四、踩坑与注意事项
4.1 AVMetadata 的 assetId 不能为空
assetId 是 AVMetadata 的唯一必填字段。如果为空,系统可能无法正确识别和显示媒体信息。
// ❌ 错误:assetId 为空
const metadata: avsession.AVMetadata = {
assetId: '', // 空字符串,系统无法识别
title: '夜曲',
};
// ✅ 正确:使用唯一 ID
const metadata: avsession.AVMetadata = {
assetId: `song_${songId}`, // 如 'song_001'
title: '夜曲',
};
4.2 封面图的内存管理
封面图(PixelMap)是内存大户。一张 1000x1000 的 RGBA 图片占用约 4MB 内存。如果同时加载多张封面,很容易导致内存溢出。
// ❌ 错误:同时加载大量封面
for (const song of songList) {
const cover = await extractCover(song.filePath); // 每张 4MB,100 首歌 = 400MB!
covers.push(cover);
}
// ✅ 正确:只加载当前和相邻歌曲的封面
async function loadNearbyCovers(currentIndex: number): Promise<void> {
// 只加载当前、上一首、下一首的封面
const indices = [currentIndex - 1, currentIndex, currentIndex + 1];
for (const i of indices) {
if (i >= 0 && i < songList.length && !covers.has(i)) {
const cover = await extractCover(songList[i].filePath);
covers.set(i, cover);
}
}
// 释放远处的封面
for (const [index, cover] of covers) {
if (Math.abs(index - currentIndex) > 1) {
cover.release();
covers.delete(index);
}
}
}
4.3 ID3 标签编码问题
ID3v2 标签的文本编码可能是 ISO-8859-1(Latin-1)或 UTF-16/UTF-8。如果编码不匹配,中文标题会显示为乱码。
// 提取元数据后检查编码
function fixEncoding(text: string): string {
// 如果文本包含乱码特征,尝试重新解码
if (text.includes('ÿþ') || text.includes('Ã')) {
console.warn('[Metadata] 检测到编码问题,可能需要手动处理');
// 实际项目中需要根据具体编码进行转换
}
return text;
}
4.4 元数据提取的耗时
元数据提取涉及文件 I/O 和解码操作,对于大文件(如高分辨率封面、长视频)可能耗时较长。建议在后台线程中执行。
import { taskpool } from '@kit.ArkTS';
// 在后台线程提取元数据
@Concurrent
async function extractMetadataInBackground(filePath: string): Promise<Record<string, string>> {
const extractor = await media.createAVMetadataExtractor();
// ... 提取逻辑
const metadata = await extractor.resolveMetadata();
await extractor.release();
return metadata;
}
async function extractAsync(filePath: string): Promise<void> {
const task = new taskpool.Task(extractMetadataInBackground, filePath);
const result = await taskpool.execute(task) as Record<string, string>;
console.info(`[Extract] 后台提取完成: ${result[media.AVMetadataKey.METADATA_KEY_TITLE]}`);
}
4.5 封面图提取失败的兜底
不是所有音频文件都有嵌入封面。特别是老歌、网络下载的低质量文件,经常缺少封面。必须做好兜底处理。
async function getCoverWithFallback(
filePath: string,
defaultCoverUrl: string
): Promise<avsession.AVMediaImage> {
// 尝试从文件提取封面
const coverManager = new CoverImageManager();
const extractedCover = await coverManager.extractCoverAsMediaImage(filePath);
if (extractedCover) {
return extractedCover;
}
// 兜底方案一:使用默认封面 URL
if (defaultCoverUrl) {
return avsession.createAVMediaImage(defaultCoverUrl);
}
// 兜底方案二:生成纯色占位图
const defaultCover = await coverManager.generateDefaultCover('未知');
return defaultCover;
}
4.6 AVMetadata 与文件元数据的区别
这是一个容易混淆的概念。AVMetadata 是 AVSession 使用的运行时数据结构,而文件元数据是嵌入在文件中的持久化数据。两者的关系是:
- 文件元数据 → 提取 → AVMetadata → 发布到 AVSession
- 修改 AVMetadata 不会修改文件中的元数据
- 修改文件元数据需要使用专门的编辑工具
// 修改 AVMetadata 只影响 AVSession 显示,不影响文件
const metadata: avsession.AVMetadata = {
assetId: '001',
title: '修改后的标题', // 只在锁屏/通知栏显示,文件中的标题不变
};
await session.setAVMetadata(metadata);
// 要修改文件中的 ID3 标签,需要使用 AVMetadataGenerator(HarmonyOS 6+)
五、HarmonyOS 6 适配
5.1 API 变更
| 变更项 | HarmonyOS 5 | HarmonyOS 6 |
|---|---|---|
| 元数据提取 | AVMetadataExtractor |
新增异步提取 API,性能提升 30% |
| 封面提取 | fetchAlbumCover() |
新增 fetchAlbumCoverHD() 支持高清封面 |
| 元数据写入 | 无 | 新增 AVMetadataGenerator 支持写入 ID3 标签 |
| 歌词 | 无 | AVMetadata 新增 lyric 字段,支持 LRC 歌词 |
| 多语言 | 无 | 新增 language 字段支持多语言元数据 |
5.2 元数据写入
HarmonyOS 6 新增了元数据写入能力,可以修改音频文件的 ID3 标签:
// HarmonyOS 6 新增:写入元数据
const generator = await media.createAVMetadataGenerator();
// 设置新的元数据
generator.setMetadata({
[media.AVMetadataKey.METADATA_KEY_TITLE]: '新标题',
[media.AVMetadataKey.METADATA_KEY_ARTIST]: '新艺术家',
[media.AVMetadataKey.METADATA_KEY_ALBUM]: '新专辑',
});
// 设置新封面
await generator.setAlbumCover(newCoverImageData);
// 写入文件
await generator.applyToFile(outputFilePath);
await generator.release();
5.3 LRC 歌词支持
// HarmonyOS 6 新增:歌词字段
const metadata: avsession.AVMetadata = {
assetId: '001',
title: '夜曲',
artist: '周杰伦',
lyric: `[ti:夜曲]
[ar:周杰伦]
[al:十一月的萧邦]
[00:00.00]夜曲 - 周杰伦
[00:04.50]词:方文山
[00:09.00]曲:周杰伦
[00:20.50]一群嗜血的蚂蚁
[00:24.00]被腐肉所吸引`,
};
5.4 迁移要点
- 将封面提取替换为
fetchAlbumCoverHD()以获取更高质量的封面 - 使用
AVMetadataGenerator替代第三方 ID3 编辑库 - 歌词字段
lyric使用标准 LRC 格式,系统会自动解析和同步 - 多语言元数据需要设置
language字段,默认为系统语言
六、总结
| 知识点 | 核心内容 |
|---|---|
| 元数据本质 | 「关于数据的数据」,包括标题、艺术家、专辑、封面等信息 |
| 两个层面 | 文件层面(ID3 标签)和 AVSession 层面(AVMetadata),前者是数据源,后者是展示层 |
| AVMetadata | AVSession 的元数据结构,assetId 是唯一必填字段 |
| ID3 标签 | MP3 文件的元数据标准,ID3v2 支持长文本和封面图 |
| 元数据提取 | AVMetadataExtractor + resolveMetadata() + fetchAlbumCover() |
| 封面图处理 | 支持 URI、PixelMap 两种方式,注意内存管理和压缩 |
| 封面图来源 | 文件提取、网络 URL、应用资源、动态生成四种方式 |
| 元数据编辑 | 提取 → 转换 → 合并 → 发布的完整流程 |
| 内存管理 | 封面图是内存大户,只加载当前和相邻歌曲的封面 |
| 编码问题 | ID3v2 可能使用不同编码,中文标题可能显示乱码 |
| HarmonyOS 6 | 新增元数据写入、高清封面、LRC 歌词、多语言支持 |
💡 一句话总结:元数据是媒体文件的「身份证」,从文件提取 ID3 标签 → 转换为 AVMetadata → 发布到 AVSession,这是播放器元数据管理的标准链路。封面图是内存大户,务必做好压缩和缓存管理。HarmonyOS 6 新增了元数据写入和歌词支持,让播放器功能更加完整。
- 点赞
- 收藏
- 关注作者
评论(0)