已开启
feat: 媒体列表与歌单失效项清理优化 #22
feat: 媒体列表与歌单失效项清理优化 #22
已开启
若水随风创建于 8 天前
共 11 个文件变更+380-95
@@ -325,18 +325,16 @@ export class MediaDataSource extends FileDataSource implements IMediaDataSource
325 // 立即显示内存缓存(如果有)- 使用 UNKNOWN key(包含全部类型数据)325 // 立即显示内存缓存(如果有)- 使用 UNKNOWN key(包含全部类型数据)
326 if (!forceRefresh && this.cacheManager.hasCache(MediaType.UNKNOWN)) {326 if (!forceRefresh && this.cacheManager.hasCache(MediaType.UNKNOWN)) {
327 const cachedData = this.cacheManager.getFromCache(MediaType.UNKNOWN);327 const cachedData = this.cacheManager.getFromCache(MediaType.UNKNOWN);
328- this.setData(cachedData);328+ const liveCached = await this.purgeMissingFilesFromSnapshot(cachedData);
329- // 清除初始化阶段的抑制标志,确保本次 setData 通知 UI329+ this.displayLiveMediaList(liveCached);
330- this.suppressDataNotification = false;330+ HiLog.i(TAG, `Loaded all data from memory cache: ${liveCached.length} files, filtered to ${MediaType[mediaType]}: ${this.filteredArray.length}`);
331- this.notifyDataChanged();
332- HiLog.i(TAG, `Loaded all data from memory cache: ${cachedData.length} files, filtered to ${MediaType[mediaType]}: ${this.filteredArray.length}`);
333 return;331 return;
334 }332 }
335 333 
336 // 确保数据库已初始化后再查询334 // 确保数据库已初始化后再查询
337 await this.dbManager.init();335 await this.dbManager.init();
338 336 
339-// 关键修复:强制刷新时执行完整扫描337+ // 关键修复:强制刷新时执行完整扫描
340 // 修复根因:forceRefresh=true 仅从本地 DB 查询,无法发现 mediatool 导入的新文件338 // 修复根因:forceRefresh=true 仅从本地 DB 查询,无法发现 mediatool 导入的新文件
341 // 完整扫描通过 UserFileScanner / MediaLibraryScanner 查询系统媒体数据库,可以发现新文件339 // 完整扫描通过 UserFileScanner / MediaLibraryScanner 查询系统媒体数据库,可以发现新文件
342 if (forceRefresh) {340 if (forceRefresh) {
@@ -345,19 +343,15 @@ export class MediaDataSource extends FileDataSource implements IMediaDataSource
345 return;343 return;
346 }344 }
347 345 
348- // 立即显示数据库缓存(不等待扫描)346+ // 立即显示数据库缓存(不等待扫描),但必须先剔除源文件已删除的记录
349 // 查询全部类型数据,setData 自动按 mediaType 过滤显示347 // 查询全部类型数据,setData 自动按 mediaType 过滤显示
350 const dbData = await this.dbManager.queryMediaMetadata();348 const dbData = await this.dbManager.queryMediaMetadata();
351 if (dbData.length > 0) {349 if (dbData.length > 0) {
352- // 冷启动优化:DB 有缓存时立即通知 UI 显示,不等待文件系统扫描+fillDuration350+ const liveData = await this.purgeMissingFilesFromSnapshot(dbData);
353- this.suppressDataNotification = false;351+ this.displayLiveMediaList(liveData);
354- this.setData(dbData);352+ HiLog.i(TAG, `Loaded all from database cache: ${liveData.length} files, filtered to ${MediaType[mediaType]}: ${this.filteredArray.length}`);
355- // 缓存全部类型数据,用 UNKNOWN key
356- this.cacheManager.updateCache(MediaType.UNKNOWN, dbData);
357- this.notifyDataChanged();
358- HiLog.i(TAG, `Loaded all from database cache: ${dbData.length} files, filtered to ${MediaType[mediaType]}: ${this.filteredArray.length}`);
359 353 
360- // 延迟到首帧后全量扫描,发现关闭期间媒体库/公共目录的增删并刷新列表354+ // 延迟到首帧后全量扫描,发现关闭期间媒体库/公共目录的新增并刷新列表
361 setTimeout(async () => {355 setTimeout(async () => {
362 try {356 try {
363 await this.scanMediaFiles(MediaType.UNKNOWN);357 await this.scanMediaFiles(MediaType.UNKNOWN);
@@ -369,7 +363,7 @@ export class MediaDataSource extends FileDataSource implements IMediaDataSource
369 // 数据库无数据,执行全量扫描(首次启动场景)363 // 数据库无数据,执行全量扫描(首次启动场景)
370 HiLog.i(TAG, `No cached data in database, performing full scan (all types)`);364 HiLog.i(TAG, `No cached data in database, performing full scan (all types)`);
371 await this.scanMediaFiles(MediaType.UNKNOWN);365 await this.scanMediaFiles(MediaType.UNKNOWN);
372-}366+ }
373 } catch (error) {367 } catch (error) {
374 HiLog.e(TAG, `Failed to load media files: ${error}`);368 HiLog.e(TAG, `Failed to load media files: ${error}`);
375 throw new Error(`加载媒体文件失败: ${error}`);369 throw new Error(`加载媒体文件失败: ${error}`);
@@ -480,7 +474,7 @@ export class MediaDataSource extends FileDataSource implements IMediaDataSource
480 // DB 软删除(is_valid=0),仅移除展示,不删除本地源文件474 // DB 软删除(is_valid=0),仅移除展示,不删除本地源文件
481 await this.dbManager.batchMarkAsInvalid(uris);475 await this.dbManager.batchMarkAsInvalid(uris);
482 // 同步内存缓存,避免非强制刷新时从缓存复现已删项476 // 同步内存缓存,避免非强制刷新时从缓存复现已删项
483- this.cacheManager.updateCache(MediaType.UNKNOWN, remainAll);477+ this.updateOrClearMemoryCache(remainAll);
484 // 重建 dataArray + filteredArray 并通知 UI(setData 自含过滤/排序/notify)478 // 重建 dataArray + filteredArray 并通知 UI(setData 自含过滤/排序/notify)
485 this.setData(remainAll);479 this.setData(remainAll);
486 HiLog.i(TAG, `softDeleteByUris removed=${removedCount} remain=${remainAll.length}`);480 HiLog.i(TAG, `softDeleteByUris removed=${removedCount} remain=${remainAll.length}`);
@@ -858,41 +852,70 @@ export class MediaDataSource extends FileDataSource implements IMediaDataSource
858 /**852 /**
859 * 检查文件是否存在,移除已删除的文件(DB 软删除 + 内存清理)。853 * 检查文件是否存在,移除已删除的文件(DB 软删除 + 内存清理)。
860 * 扫描完成后调用,确保列表中不包含源文件已被删除的条目。854 * 扫描完成后调用,确保列表中不包含源文件已被删除的条目。
861- * 对 file://media URI 使用 MediaPlaybackFileOpener 真正打开检测,
862- * 对 file://docs / 沙箱 URI 使用 fs.accessSync 检查物理文件。
863 */855 */
864 async removeDeletedFiles(files: FileInfo[]): Promise<number> {856 async removeDeletedFiles(files: FileInfo[]): Promise<number> {
865 if (files.length === 0) {857 if (files.length === 0) {
866 return 0;858 return 0;
867 }859 }
860+ const deletedUris = await this.collectMissingFileUris(files);
861+ if (deletedUris.length === 0) {
862+ return 0;
863+ }
864+ 
865+ HiLog.i(TAG, `Removed deleted files: ${deletedUris.length}`);
866+ await this.softDeleteByUris(deletedUris);
867+ return deletedUris.length;
868+ }
869+ 
870+ /**
871+ * 首屏前剔除已删除源文件并写回 DB,避免冷启动先展示失效缓存。
872+ */
873+ private async purgeMissingFilesFromSnapshot(files: FileInfo[]): Promise<FileInfo[]> {
874+ const missingUris = await this.collectMissingFileUris(files);
875+ if (missingUris.length === 0) {
876+ return files;
877+ }
878+ const missingSet: Set<string> = new Set(missingUris);
879+ const liveFiles = files.filter((file: FileInfo) => !missingSet.has(file.uri));
880+ await this.dbManager.batchMarkAsInvalid(missingUris);
881+ HiLog.i(TAG, `Purged missing files before display: ${missingUris.length}, remain=${liveFiles.length}`);
882+ return liveFiles;
883+ }
884+ 
885+ /**
886+ * 热启动/回前台:按当前列表核对源文件,立刻剔除已删除项并通知 UI。
887+ *
888+ * @returns 移除的条目数
889+ */
890+ public async reconcileMissingFiles(): Promise<number> {
891+ if (this.isScanning()) {
892+ HiLog.i(TAG, 'reconcileMissingFiles skipped: scan running');
893+ return 0;
894+ }
895+ const current = this.getDataArray();
896+ if (current.length === 0) {
897+ return 0;
898+ }
899+ const liveFiles = await this.purgeMissingFilesFromSnapshot(current);
900+ const removed = current.length - liveFiles.length;
901+ if (removed === 0) {
902+ return 0;
903+ }
904+ this.displayLiveMediaList(liveFiles);
905+ HiLog.i(TAG, `reconcileMissingFiles removed=${removed} remain=${liveFiles.length}`);
906+ return removed;
907+ }
908+ 
909+ private async collectMissingFileUris(files: FileInfo[]): Promise<string[]> {
868 const deletedUris: string[] = [];910 const deletedUris: string[] = [];
869- for (const file of files) {911+ for (let i = 0; i < files.length; i++) {
912+ const file = files[i];
870 if (file.uri.indexOf('/.Trash') >= 0) {913 if (file.uri.indexOf('/.Trash') >= 0) {
871 deletedUris.push(file.uri);914 deletedUris.push(file.uri);
872 continue;915 continue;
873 }916 }
874 try {917 try {
875- let exists = false;918+ const exists = await this.sourceFileExists(file);
876- if (file.uri.startsWith('file://media/')) {
877- // 媒体库 URI:真正打开文件检测
878- const opened = await MediaPlaybackFileOpener.openReadOnly(file.uri, file.fileName);
879- if (opened) {
880- await opened.release();
881- exists = true;
882- }
883- } else {
884- // file://docs / 沙箱 URI:fs.accessSync 检查物理文件
885- try {
886- fs.accessSync(file.uri);
887- exists = true;
888- } catch (_) {
889- const path = FileUtil.getPathFromUri(file.uri);
890- if (path.length > 0 && path !== file.uri) {
891- fs.accessSync(path);
892- exists = true;
893- }
894- }
895- }
896 if (!exists) {919 if (!exists) {
897 deletedUris.push(file.uri);920 deletedUris.push(file.uri);
898 HiLog.w(TAG, `Source file deleted: ${file.fileName} uri=${file.uri}`);921 HiLog.w(TAG, `Source file deleted: ${file.fileName} uri=${file.uri}`);
@@ -902,14 +925,49 @@ export class MediaDataSource extends FileDataSource implements IMediaDataSource
902 HiLog.w(TAG, `File check exception treated as deleted: ${file.fileName} error=${error}`);925 HiLog.w(TAG, `File check exception treated as deleted: ${file.fileName} error=${error}`);
903 }926 }
904 }927 }
928+ return deletedUris;
929+ }
905 930 
906- if (deletedUris.length === 0) {931+ private async sourceFileExists(file: FileInfo): Promise<boolean> {
907- return 0;932+ if (file.uri.startsWith('file://media/')) {
933+ const opened = await MediaPlaybackFileOpener.openReadOnly(file.uri, file.fileName);
934+ if (opened) {
935+ await opened.release();
936+ return true;
937+ }
938+ return false;
908 }939 }
940+ try {
941+ fs.statSync(file.uri);
942+ return true;
943+ } catch (_) {
944+ const storedPath = file.path ?? '';
945+ const path = storedPath.length > 0 ? storedPath : FileUtil.getPathFromUri(file.uri);
946+ if (path.length > 0 && path !== file.uri) {
947+ try {
948+ fs.statSync(path);
949+ return true;
950+ } catch (__) {
951+ return false;
952+ }
953+ }
954+ return false;
955+ }
956+ }
909 957 
910- HiLog.i(TAG, `Removed deleted files: ${deletedUris.length}`);958+ private displayLiveMediaList(files: FileInfo[]): void {
911- await this.softDeleteByUris(deletedUris);959+ this.suppressDataNotification = false;
912- return deletedUris.length;960+ this.setData(files);
961+ this.updateOrClearMemoryCache(files);
962+ this.notifyDataChanged();
963+ }
964+ 
965+ private updateOrClearMemoryCache(files: FileInfo[]): void {
966+ if (files.length > 0) {
967+ this.cacheManager.updateCache(MediaType.UNKNOWN, files);
968+ return;
969+ }
970+ this.cacheManager.clearCache(MediaType.UNKNOWN);
913 }971 }
914 972 
915 /**973 /**
@@ -1110,10 +1168,16 @@ export class MediaDataSource extends FileDataSource implements IMediaDataSource
1110 await this.dbManager.init();1168 await this.dbManager.init();
1111 1169 
1112 // 从数据库查询全部类型数据(mtime 值来自 DB,与初始加载一致)1170 // 从数据库查询全部类型数据(mtime 值来自 DB,与初始加载一致)
1113- const updatedData = await this.dbManager.queryMediaMetadata();1171+ const dbData = await this.dbManager.queryMediaMetadata();
1172+ const updatedData = await this.purgeMissingFilesFromSnapshot(dbData);
1114 1173 
1115 if (updatedData.length === 0) {1174 if (updatedData.length === 0) {
1116- HiLog.i(TAG, `Incremental refresh: no data in DB, skipping`);1175+ if (this.getDataArray().length > 0) {
1176+ HiLog.i(TAG, 'Incremental refresh: no live data, clearing displayed list');
1177+ this.displayLiveMediaList([]);
1178+ } else {
1179+ HiLog.i(TAG, `Incremental refresh: no data in DB, skipping`);
1180+ }
1117 return;1181 return;
1118 }1182 }
1119 1183 
@@ -504,6 +504,111 @@ export class MediaDbManager {
504 }504 }
505 }505 }
506 506 
507+ /**
508+ * 从全部歌单移除指定 URI(文管删除/扫描失效后调用,避免残留曲目)
509+ *
510+ * @param uris 媒体 URI 列表
511+ * @returns 删除的 playlist_item 行数
512+ */
513+ async removePlaylistItemsByUris(uris: string[]): Promise<number> {
514+ if (uris.length === 0) {
515+ return 0;
516+ }
517+ const candidates = this.collectPlaylistUriCandidates(uris);
518+ if (candidates.length === 0) {
519+ return 0;
520+ }
521+ try {
522+ const deleted = await this.playlistItemTable.deleteData(
523+ new relationalStore.RdbPredicates('playlist_item').in('uri', candidates)
524+ );
525+ HiLog.i(TAG, `Removed playlist items by uri rows=${deleted} input=${uris.length}`);
526+ return deleted;
527+ } catch (error) {
528+ HiLog.e(TAG, `Failed to remove playlist items by uri: ${error}`);
529+ return 0;
530+ }
531+ }
532+ 
533+ /**
534+ * 查询全部歌单中的去重曲目 URI
535+ *
536+ * @returns 歌单曲目 URI 列表
537+ */
538+ private async getAllPlaylistItemUris(): Promise<string[]> {
539+ try {
540+ const resultSet = await this.playlistItemTable.queryData(
541+ new relationalStore.RdbPredicates('playlist_item')
542+ );
543+ if (!resultSet) {
544+ return [];
545+ }
546+ const uriSet: Set<string> = new Set();
547+ while (resultSet.goToNextRow()) {
548+ const uriIndex = resultSet.getColumnIndex('uri');
549+ uriSet.add(resultSet.getString(uriIndex));
550+ }
551+ resultSet.close();
552+ return Array.from(uriSet);
553+ } catch (error) {
554+ HiLog.e(TAG, `Failed to query all playlist item uris: ${error}`);
555+ return [];
556+ }
557+ }
558+ 
559+ /**
560+ * 清理全部歌单中已无有效媒体元数据的曲目(冷启动/进入歌单时调用)
561+ *
562+ * @returns 删除的 playlist_item 行数
563+ */
564+ async pruneInvalidPlaylistItems(): Promise<number> {
565+ try {
566+ const uris = await this.getAllPlaylistItemUris();
567+ const invalidUris: string[] = [];
568+ for (let i = 0; i < uris.length; i++) {
569+ const meta = await this.queryMediaMetadataByUri(uris[i]);
570+ if (meta === null) {
571+ invalidUris.push(uris[i]);
572+ }
573+ }
574+ if (invalidUris.length === 0) {
575+ return 0;
576+ }
577+ const removed = await this.removePlaylistItemsByUris(invalidUris);
578+ HiLog.i(TAG, `Pruned invalid playlist items uris=${invalidUris.length} rows=${removed}`);
579+ return removed;
580+ } catch (error) {
581+ HiLog.e(TAG, `Failed to prune invalid playlist items: ${error}`);
582+ return 0;
583+ }
584+ }
585+ 
586+ private collectPlaylistUriCandidates(uris: string[]): string[] {
587+ const candidateSet: Set<string> = new Set();
588+ for (let i = 0; i < uris.length; i++) {
589+ const uri = uris[i];
590+ if (StringUtil.isEmpty(uri)) {
591+ continue;
592+ }
593+ candidateSet.add(uri);
594+ const normalized = FileUtil.normalizeMediaUri(uri);
595+ if (!StringUtil.isEmpty(normalized)) {
596+ candidateSet.add(normalized);
597+ }
598+ const filePath = FileUtil.getPathFromUri(uri);
599+ if (!StringUtil.isEmpty(filePath)) {
600+ candidateSet.add(filePath);
601+ }
602+ if (uri.indexOf('://') < 0) {
603+ const uriFromPath = FileUtil.getUriFromPath(uri);
604+ if (!StringUtil.isEmpty(uriFromPath)) {
605+ candidateSet.add(uriFromPath);
606+ }
607+ }
608+ }
609+ return Array.from(candidateSet);
610+ }
611+ 
507 /**612 /**
508 * 判断 URI 是否已在播放列表中613 * 判断 URI 是否已在播放列表中
509 *614 *
@@ -955,23 +1060,7 @@ export class MediaDbManager {
955 }1060 }
956 1061 
957 async markAsInvalid(uri: string): Promise<boolean> {1062 async markAsInvalid(uri: string): Promise<boolean> {
958- try {1063+ return await this.batchMarkAsInvalid([uri]);
959- const values: relationalStore.ValuesBucket = {
960- is_valid: 0,
961- update_time: Date.now()
962- };
963- 
964- await this.mediaMetadataTable.updateData(
965- values,
966- new relationalStore.RdbPredicates('media_metadata').equalTo('uri', uri)
967- );
968- 
969- HiLog.i(TAG, `Marked as invalid: ${uri}`);
970- return true;
971- } catch (error) {
972- HiLog.e(TAG, `Failed to mark as invalid: ${error}`);
973- return false;
974- }
975 }1064 }
976 1065 
977 /**1066 /**
@@ -986,6 +1075,10 @@ export class MediaDbManager {
986 return true;1075 return true;
987 }1076 }
988 try {1077 try {
1078+ const candidates = this.collectPlaylistUriCandidates(uris);
1079+ if (candidates.length === 0) {
1080+ return true;
1081+ }
989 const values: relationalStore.ValuesBucket = {1082 const values: relationalStore.ValuesBucket = {
990 is_valid: 0,1083 is_valid: 0,
991 update_time: Date.now()1084 update_time: Date.now()
@@ -993,10 +1086,15 @@ export class MediaDbManager {
993 1086 
994 await this.mediaMetadataTable.updateData(1087 await this.mediaMetadataTable.updateData(
995 values,1088 values,
996- new relationalStore.RdbPredicates('media_metadata').in('uri', uris)1089+ new relationalStore.RdbPredicates('media_metadata').in('uri', candidates)
997 );1090 );
1091+ await this.mediaMetadataTable.updateData(
1092+ values,
1093+ new relationalStore.RdbPredicates('media_metadata').in('file_path', candidates)
1094+ );
1095+ const playlistRemoved = await this.removePlaylistItemsByUris(candidates);
998 1096 
999- HiLog.i(TAG, `Batch mark as invalid completed, count=${uris.length}`);1097+ HiLog.i(TAG, `Batch mark as invalid completed, count=${uris.length}, candidates=${candidates.length}, playlistRemoved=${playlistRemoved}`);
1000 return true;1098 return true;
1001 } catch (error) {1099 } catch (error) {
1002 const message = error instanceof Error ? error.message : String(error);1100 const message = error instanceof Error ? error.message : String(error);
@@ -72,6 +72,9 @@ export struct PlaylistAddAudioNavPage {
72 },72 },
73 onAddTrack: (track: FileInfo) => {73 onAddTrack: (track: FileInfo) => {
74 this.viewModel.addAudioToCurrentPlaylist(track);74 this.viewModel.addAudioToCurrentPlaylist(track);
75+ },
76+ onEnsureCover: (track: FileInfo) => {
77+ this.viewModel.ensureAddAudioTrackCover(track);
75 }78 }
76 })79 })
77 .width('100%')80 .width('100%')
@@ -425,6 +425,29 @@ export class HomeViewModel {
425 this.restoreMiniPlayerSessionOnColdStart();425 this.restoreMiniPlayerSessionOnColdStart();
426 this.flushDeferredQueueUiIfNeeded();426 this.flushDeferredQueueUiIfNeeded();
427 this.notifyUiState();427 this.notifyUiState();
428+ this.reconcileMissingMediaOnForeground();
429+ }
430+ 
431+ /**
432+ * 从文管等外部应用回前台时,按源文件存在性剔除已删除媒体。
433+ * 冷启动列表尚未加载时 dataArray 为空,此处会直接跳过。
434+ */
435+ private reconcileMissingMediaOnForeground(): void {
436+ const dataSource = this.mediaViewModel.getMediaDataSource();
437+ if (!dataSource) {
438+ return;
439+ }
440+ void dataSource.reconcileMissingFiles().then((removed: number) => {
441+ if (removed <= 0) {
442+ return;
443+ }
444+ this.syncMediaDisplayState();
445+ this.syncMediaListUiState();
446+ this.notifyUiState();
447+ HiLog.i(TAG, `reconcileMissingMediaOnForeground removed=${removed}`);
448+ }).catch((error: Error) => {
449+ HiLog.e(TAG, `reconcileMissingMediaOnForeground failed: ${error.message}`);
450+ });
428 }451 }
429 452 
430 /**453 /**
@@ -31,6 +31,8 @@ import {
31} from '@ohos/playlist';31} from '@ohos/playlist';
32import { MediaDbManager, PlaylistRecord } from '@ohos/common/src/main/ets/persistence/rdb/MediaDbManager';32import { MediaDbManager, PlaylistRecord } from '@ohos/common/src/main/ets/persistence/rdb/MediaDbManager';
33import { AudioItem } from '@ohos/common/src/main/ets/model/AudioItem';33import { AudioItem } from '@ohos/common/src/main/ets/model/AudioItem';
34+import { MediaType } from '@ohos/common/src/main/ets/datasource/MediaDataSource';
35+import { MediaAggregateViewModel } from '@ohos/media/src/main/ets/manager/MediaAggregateViewModel';
34import { common } from '@kit.AbilityKit';36import { common } from '@kit.AbilityKit';
35import { promptAction } from '@kit.ArkUI';37import { promptAction } from '@kit.ArkUI';
36import { HomeViewModel } from './HomeViewModel';38import { HomeViewModel } from './HomeViewModel';
@@ -116,6 +118,7 @@ export class PlaylistViewModel {
116 /** 本次会话内用户点 X 隐藏续播条(杀进程后清空;播放中隐藏不影响持久化) */118 /** 本次会话内用户点 X 隐藏续播条(杀进程后清空;播放中隐藏不影响持久化) */
117 private sessionDismissedPlaylistIds: Set<number> = new Set();119 private sessionDismissedPlaylistIds: Set<number> = new Set();
118 private resumeListenerRegistered: boolean = false;120 private resumeListenerRegistered: boolean = false;
121+ private mediaListenerRegistered: boolean = false;
119 private lastNotifiedDetailResumeBar: boolean = false;122 private lastNotifiedDetailResumeBar: boolean = false;
120 private lastNotifiedDetailResumeLabel: string = '';123 private lastNotifiedDetailResumeLabel: string = '';
121 private detailTrackRemoveInFlight: boolean = false;124 private detailTrackRemoveInFlight: boolean = false;
@@ -143,6 +146,7 @@ export class PlaylistViewModel {
143 */146 */
144 public init(context: common.UIAbilityContext | null): void {147 public init(context: common.UIAbilityContext | null): void {
145 this.ensureResumeChangeListener();148 this.ensureResumeChangeListener();
149+ this.ensureMediaDataChangeListener();
146 if (!context || this.initialized) {150 if (!context || this.initialized) {
147 return;151 return;
148 }152 }
@@ -221,7 +225,13 @@ export class PlaylistViewModel {
221 HiLog.w(TAG, `loadPlaylists skipped db not ready`);225 HiLog.w(TAG, `loadPlaylists skipped db not ready`);
222 return;226 return;
223 }227 }
228+ const pruned = await this.dbManager.pruneInvalidPlaylistItems();
229+ HiLog.i(TAG, `loadPlaylists prunedInvalid=${pruned}`);
224 const records = await this.dbManager.getAllPlaylists();230 const records = await this.dbManager.getAllPlaylists();
231+ if (pruned === 0 && this.isSamePlaylistRecords(records)) {
232+ HiLog.i(TAG, `loadPlaylists skipped unchanged count=${records.length}`);
233+ return;
234+ }
225 const playlistIds = records.map((record: PlaylistRecord) => record.id);235 const playlistIds = records.map((record: PlaylistRecord) => record.id);
226 const firstUriMap = await this.dbManager.getPlaylistFirstTrackUriMap(playlistIds);236 const firstUriMap = await this.dbManager.getPlaylistFirstTrackUriMap(playlistIds);
227 const mapped = records.map((record: PlaylistRecord) => this.mapRecordToPlaylistItem(record));237 const mapped = records.map((record: PlaylistRecord) => this.mapRecordToPlaylistItem(record));
@@ -244,6 +254,20 @@ export class PlaylistViewModel {
244 this.notifyUiState();254 this.notifyUiState();
245 }255 }
246 256 
257+ private isSamePlaylistRecords(records: PlaylistRecord[]): boolean {
258+ if (records.length !== this.playlists.length) {
259+ return false;
260+ }
261+ for (let i = 0; i < records.length; i++) {
262+ const current = this.playlists[i];
263+ const record = records[i];
264+ if (current.id !== record.id || current.name !== record.name || current.count !== record.itemCount) {
265+ return false;
266+ }
267+ }
268+ return true;
269+ }
270+ 
247 /** 打开创建播放列表名称弹窗 */271 /** 打开创建播放列表名称弹窗 */
248 public openCreateDialog(): void {272 public openCreateDialog(): void {
249 this.nameDialogSession++;273 this.nameDialogSession++;
@@ -507,7 +531,7 @@ export class PlaylistViewModel {
507 this.notifyUiState();531 this.notifyUiState();
508 }532 }
509 533 
510- /** 打开添加音频页并加载可选音频 */534+ /** 打开添加音频页并加载可选音频(封面由列表行可见时懒加载,避免主线程卡死) */
511 public async openAddAudioPage(): Promise<void> {535 public async openAddAudioPage(): Promise<void> {
512 if (!this.dbManager || this.detailPlaylistId < 0) {536 if (!this.dbManager || this.detailPlaylistId < 0) {
513 return;537 return;
@@ -519,19 +543,24 @@ export class PlaylistViewModel {
519 this.addAudioTracks = [];543 this.addAudioTracks = [];
520 for (const raw of rawTracks) {544 for (const raw of rawTracks) {
521 raw.mimeType = 'audio/*';545 raw.mimeType = 'audio/*';
522- const track = FileInfo.fromFileOperateFileInfo(raw);546+ this.addAudioTracks.push(FileInfo.fromFileOperateFileInfo(raw));
523- // 添加音频页与首页共用封面来源:DB 为空时合并内存扫描与系统 media:// 缩略图
524- PlaylistCoverUtil.applyAvailableCovers(track);
525- this.addAudioTracks.push(track);
526 }547 }
527 await this.refreshAddAudioInPlaylistUris();548 await this.refreshAddAudioInPlaylistUris();
528 this.applyAddAudioFilter();549 this.applyAddAudioFilter();
529- const withCover = this.addAudioTracks.filter((item: FileInfo) => item.coverUri.length > 0).length;550+ HiLog.i(TAG, `openAddAudioPage candidates=${this.addAudioTracks.length} inPlaylist=${this.addAudioInPlaylistUriSet.size}`);
530- HiLog.i(TAG, `openAddAudioPage candidates=${this.addAudioTracks.length} withCover=${withCover} inPlaylist=${this.addAudioInPlaylistUriSet.size}`);
531 NavRouterHelper.push(AppNavRoutes.PLAYLIST_ADD_AUDIO);551 NavRouterHelper.push(AppNavRoutes.PLAYLIST_ADD_AUDIO);
532 this.notifyUiState();552 this.notifyUiState();
533 }553 }
534 554 
555+ /**
556+ * 添加音频页列表行可见时补全封面(内存扫描 / 系统 media://)
557+ *
558+ * @param track 当前行音频
559+ */
560+ public ensureAddAudioTrackCover(track: FileInfo): void {
561+ PlaylistCoverUtil.applyAvailableCovers(track);
562+ }
563+ 
535 /**564 /**
536 * 刷新添加页「已在歌单」URI 集合(来自当前详情歌单曲目)565 * 刷新添加页「已在歌单」URI 集合(来自当前详情歌单曲目)
537 */566 */
@@ -608,12 +637,20 @@ export class PlaylistViewModel {
608 return;637 return;
609 }638 }
610 const tracks: FileInfo[] = [];639 const tracks: FileInfo[] = [];
640+ const missingUris: string[] = [];
611 for (let i = 0; i < uris.length; i++) {641 for (let i = 0; i < uris.length; i++) {
612 const fileInfo = await this.resolveFileInfo(uris[i]);642 const fileInfo = await this.resolveFileInfo(uris[i]);
613 if (fileInfo) {643 if (fileInfo) {
614 tracks.push(fileInfo);644 tracks.push(fileInfo);
645+ } else {
646+ missingUris.push(uris[i]);
615 }647 }
616 }648 }
649+ if (missingUris.length > 0) {
650+ await this.dbManager.removePlaylistItemsByUris(missingUris);
651+ await this.patchPlaylistItemCover(item.id);
652+ HiLog.i(TAG, `playPlaylist pruned missing count=${missingUris.length} playlistId=${item.id}`);
653+ }
617 if (tracks.length === 0) {654 if (tracks.length === 0) {
618 promptAction.showToast({ message: PlaylistToastConstants.PLAY_EMPTY });655 promptAction.showToast({ message: PlaylistToastConstants.PLAY_EMPTY });
619 return;656 return;
@@ -1147,12 +1184,23 @@ export class PlaylistViewModel {
1147 }1184 }
1148 const uris = await this.dbManager.getPlaylistItems(this.detailPlaylistId);1185 const uris = await this.dbManager.getPlaylistItems(this.detailPlaylistId);
1149 const tracks: FileInfo[] = [];1186 const tracks: FileInfo[] = [];
1187+ const missingUris: string[] = [];
1150 for (const uri of uris) {1188 for (const uri of uris) {
1151 const info = await this.resolveFileInfo(uri);1189 const info = await this.resolveFileInfo(uri);
1152 if (info) {1190 if (info) {
1153 tracks.push(info);1191 tracks.push(info);
1192+ } else {
1193+ missingUris.push(uri);
1154 }1194 }
1155 }1195 }
1196+ if (missingUris.length > 0) {
1197+ await this.dbManager.removePlaylistItemsByUris(missingUris);
1198+ HiLog.i(TAG, `reloadDetailTracks pruned missing count=${missingUris.length}`);
1199+ }
1200+ if (this.isSameDetailTrackUris(tracks)) {
1201+ HiLog.i(TAG, `reloadDetailTracks skipped unchanged count=${tracks.length}`);
1202+ return;
1203+ }
1156 this.detailTrackDataSource.setData(tracks);1204 this.detailTrackDataSource.setData(tracks);
1157 const withCover = tracks.filter((item: FileInfo) => item.coverUri.length > 0).length;1205 const withCover = tracks.filter((item: FileInfo) => item.coverUri.length > 0).length;
1158 const order = tracks.map((track: FileInfo) => track.fileName).join('>');1206 const order = tracks.map((track: FileInfo) => track.fileName).join('>');
@@ -1167,21 +1215,13 @@ export class PlaylistViewModel {
1167 if (!this.dbManager) {1215 if (!this.dbManager) {
1168 return null;1216 return null;
1169 }1217 }
1170- let track: FileInfo | null = null;
1171 const fromDb: FileOperateFileInfo | null = await this.dbManager.queryMediaMetadataByUri(uri);1218 const fromDb: FileOperateFileInfo | null = await this.dbManager.queryMediaMetadataByUri(uri);
1172- if (fromDb) {1219+ if (!fromDb) {
1173- fromDb.mimeType = 'audio/*';1220+ HiLog.w(TAG, `resolveFileInfo missing metadata uri=${uri}`);
1174- track = FileInfo.fromFileOperateFileInfo(fromDb);1221+ return null;
1175- } else {
1176- const slash = uri.lastIndexOf('/');
1177- const fileName = slash >= 0 ? uri.substring(slash + 1) : uri;
1178- const fallback = new FileOperateFileInfo();
1179- fallback.uri = uri;
1180- fallback.path = uri;
1181- fallback.fileName = fileName;
1182- fallback.mimeType = 'audio/*';
1183- track = FileInfo.fromFileOperateFileInfo(fallback);
1184 }1222 }
1223+ fromDb.mimeType = 'audio/*';
1224+ const track = FileInfo.fromFileOperateFileInfo(fromDb);
1185 // 详情/编辑/播放队列共用:DB 封面为空时合并内存扫描与系统 media:// 缩略图1225 // 详情/编辑/播放队列共用:DB 封面为空时合并内存扫描与系统 media:// 缩略图
1186 PlaylistCoverUtil.applyAvailableCovers(track);1226 PlaylistCoverUtil.applyAvailableCovers(track);
1187 HiLog.i(TAG, `resolveFileInfo uri=${uri} hasCover=${track.coverUri.length > 0}`);1227 HiLog.i(TAG, `resolveFileInfo uri=${uri} hasCover=${track.coverUri.length > 0}`);
@@ -1330,6 +1370,40 @@ export class PlaylistViewModel {
1330 HiLog.i(TAG, 'resume change listener registered');1370 HiLog.i(TAG, 'resume change listener registered');
1331 }1371 }
1332 1372 
1373+ private ensureMediaDataChangeListener(): void {
1374+ if (this.mediaListenerRegistered) {
1375+ return;
1376+ }
1377+ MediaAggregateViewModel.getInstance().addDataChangeListener(this.handleMediaDataChanged);
1378+ this.mediaListenerRegistered = true;
1379+ HiLog.i(TAG, 'media data change listener registered');
1380+ }
1381+ 
1382+ private handleMediaDataChanged = (mediaType: MediaType, data: FileInfo[]): void => {
1383+ HiLog.i(TAG, `handleMediaDataChanged type=${MediaType[mediaType]} count=${data.length}`);
1384+ void this.refreshPlaylistsAfterMediaChange();
1385+ };
1386+ 
1387+ private async refreshPlaylistsAfterMediaChange(): Promise<void> {
1388+ if (this.detailPlaylistId >= 0) {
1389+ await this.reloadDetailTracks();
1390+ }
1391+ await this.loadPlaylists();
1392+ }
1393+ 
1394+ private isSameDetailTrackUris(tracks: FileInfo[]): boolean {
1395+ const current = this.detailTrackDataSource.getDataArray();
1396+ if (current.length !== tracks.length) {
1397+ return false;
1398+ }
1399+ for (let i = 0; i < tracks.length; i++) {
1400+ if (current[i].uri !== tracks[i].uri) {
1401+ return false;
1402+ }
1403+ }
1404+ return true;
1405+ }
1406+ 
1333 private handleResumePersisted: PlaylistPlaybackResumeChangeListener = (playlistId: number): void => {1407 private handleResumePersisted: PlaylistPlaybackResumeChangeListener = (playlistId: number): void => {
1334 if (this.currentScreen !== PlaylistScreen.DETAIL || this.detailPlaylistId !== playlistId) {1408 if (this.currentScreen !== PlaylistScreen.DETAIL || this.detailPlaylistId !== playlistId) {
1335 return;1409 return;
@@ -306,6 +306,10 @@ export class AudioPlayerController extends AVPlayerController implements IPlayba
306 async firstPlay(uri: string, switchGeneration?: number): Promise<void> {306 async firstPlay(uri: string, switchGeneration?: number): Promise<void> {
307 // 重置停止标志(允许正常播放)307 // 重置停止标志(允许正常播放)
308 this._isStopped = false;308 this._isStopped = false;
309+ // 开源失败时 playingUri 不会写入;记下 uri,用户点播放才能重绑并弹损坏提示
310+ if (this.fileInfo.uri.length === 0) {
311+ this.fileInfo.uri = uri;
312+ }
309 this.startTime = Date.now();313 this.startTime = Date.now();
310 hiTraceMeter.startAsyncTrace(hiTraceMeter.HiTraceOutputLevel.COMMERCIAL,314 hiTraceMeter.startAsyncTrace(hiTraceMeter.HiTraceOutputLevel.COMMERCIAL,
311 'AUDIO_PARSE_FILE', TaskId.PARSE_FILE, 'PARSE_FILE');315 'AUDIO_PARSE_FILE', TaskId.PARSE_FILE, 'PARSE_FILE');
@@ -14,7 +14,7 @@
14 */14 */
15 15 
16import { promptAction } from '@kit.ArkUI';16import { promptAction } from '@kit.ArkUI';
17-import { MediaSourceAccessState } from '@ohos/common';17+import { MediaSourceAccessState, PlaybackResumeContext } from '@ohos/common';
18 18 
19/**19/**
20 * 按源文件预检结果弹出准确 Toast,避免一律「源文件已被删除」20 * 按源文件预检结果弹出准确 Toast,避免一律「源文件已被删除」
@@ -22,6 +22,10 @@ import { MediaSourceAccessState } from '@ohos/common';
22 * @param state 存在性 / 打开失败归因22 * @param state 存在性 / 打开失败归因
23 */23 */
24export function showMediaSourceFailureToast(state: MediaSourceAccessState): void {24export function showMediaSourceFailureToast(state: MediaSourceAccessState): void {
25+ // 冷启动暂停恢复会先开源:失败时吃掉 startPaused,等用户点播放再提示
26+ if (PlaybackResumeContext.consumePendingStartPaused()) {
27+ return;
28+ }
25 let message: Resource;29 let message: Resource;
26 if (state === MediaSourceAccessState.MISSING) {30 if (state === MediaSourceAccessState.MISSING) {
27 message = $r('app.string.source_file_missing');31 message = $r('app.string.source_file_missing');
@@ -35,6 +35,7 @@ export struct PlaylistAddAudioRow {
35 @Prop @Watch('onTrackOrKeywordChange') searchKeyword: string = '';35 @Prop @Watch('onTrackOrKeywordChange') searchKeyword: string = '';
36 @Prop isAlreadyAdded: boolean = false;36 @Prop isAlreadyAdded: boolean = false;
37 onAddClick: () => void = () => {};37 onAddClick: () => void = () => {};
38+ onEnsureCover: (track: FileInfo) => void = () => {};
38 39 
39 @State thumbnailSrc: string | Resource = '';40 @State thumbnailSrc: string | Resource = '';
40 @State metaText: string = '';41 @State metaText: string = '';
@@ -46,16 +47,14 @@ export struct PlaylistAddAudioRow {
46 private lastRenderedTrackKey: string = '';47 private lastRenderedTrackKey: string = '';
47 48 
48 aboutToAppear(): void {49 aboutToAppear(): void {
49- this.lastRenderedTrackKey = this.buildTrackDisplayKey();50+ this.ensureCoverAndBind();
50- this.bindTrackDisplay();
51 this.rebuildHighlightSegments();51 this.rebuildHighlightSegments();
52 }52 }
53 53 
54 onTrackOrKeywordChange(): void {54 onTrackOrKeywordChange(): void {
55 const trackKey = this.buildTrackDisplayKey();55 const trackKey = this.buildTrackDisplayKey();
56 if (trackKey !== this.lastRenderedTrackKey) {56 if (trackKey !== this.lastRenderedTrackKey) {
57- this.lastRenderedTrackKey = trackKey;57+ this.ensureCoverAndBind();
58- this.bindTrackDisplay();
59 }58 }
60 this.rebuildHighlightSegments();59 this.rebuildHighlightSegments();
61 }60 }
@@ -65,6 +64,15 @@ export struct PlaylistAddAudioRow {
65 return `${this.track.uri}|${this.track.coverUri}|${this.track.thumbUri}`;64 return `${this.track.uri}|${this.track.coverUri}|${this.track.thumbUri}`;
66 }65 }
67 66 
67+ /** 行可见时再补封面,避免打开页时全量匹配卡住主线程 */
68+ private ensureCoverAndBind(): void {
69+ if (!MediaItemDisplayUtil.hasCustomThumbnail(this.track)) {
70+ this.onEnsureCover(this.track);
71+ }
72+ this.bindTrackDisplay();
73+ this.lastRenderedTrackKey = this.buildTrackDisplayKey();
74+ }
75+ 
68 /** 绑定封面与元信息 */76 /** 绑定封面与元信息 */
69 private bindTrackDisplay(): void {77 private bindTrackDisplay(): void {
70 this.hasCustomThumb = MediaItemDisplayUtil.hasCustomThumbnail(this.track);78 this.hasCustomThumb = MediaItemDisplayUtil.hasCustomThumbnail(this.track);
@@ -33,6 +33,7 @@ export struct PlaylistAddAudioView {
33 onBack: () => void = () => {};33 onBack: () => void = () => {};
34 onSearchTextChange: (keyword: string) => void = () => {};34 onSearchTextChange: (keyword: string) => void = () => {};
35 onAddTrack: (track: FileInfo) => void = () => {};35 onAddTrack: (track: FileInfo) => void = () => {};
36+ onEnsureCover: (track: FileInfo) => void = () => {};
36 37 
37 /** 当前歌单是否已包含该音频 */38 /** 当前歌单是否已包含该音频 */
38 private isAlreadyAdded(uri: string): boolean {39 private isAlreadyAdded(uri: string): boolean {
@@ -96,6 +97,9 @@ export struct PlaylistAddAudioView {
96 isAlreadyAdded: this.isAlreadyAdded(track.uri),97 isAlreadyAdded: this.isAlreadyAdded(track.uri),
97 onAddClick: () => {98 onAddClick: () => {
98 this.onAddTrack(track);99 this.onAddTrack(track);
100+ },
101+ onEnsureCover: (item: FileInfo) => {
102+ this.onEnsureCover(item);
99 }103 }
100 })104 })
101 }105 }
@@ -138,11 +138,13 @@ export struct PlaylistCard {
138 SymbolGlyph($r('sys.symbol.dot_grid_1x2'))138 SymbolGlyph($r('sys.symbol.dot_grid_1x2'))
139 .fontSize(PlaylistConstants.GRID_CARD_MORE_ICON_SIZE)139 .fontSize(PlaylistConstants.GRID_CARD_MORE_ICON_SIZE)
140 .fontColor([PlaylistConstants.GRID_CARD_MORE_ICON_COLOR])140 .fontColor([PlaylistConstants.GRID_CARD_MORE_ICON_COLOR])
141+ .fontWeight(FontWeight.Medium)
141 .renderingStrategy(SymbolRenderingStrategy.SINGLE)142 .renderingStrategy(SymbolRenderingStrategy.SINGLE)
142 }143 }
144+ .buttonStyle(ButtonStyleMode.TEXTUAL)
143 .width(PlaylistConstants.GRID_CARD_MORE_HIT_SIZE)145 .width(PlaylistConstants.GRID_CARD_MORE_HIT_SIZE)
144 .height(PlaylistConstants.GRID_CARD_MORE_HIT_SIZE)146 .height(PlaylistConstants.GRID_CARD_MORE_HIT_SIZE)
145- .backgroundColor($r('sys.color.ohos_id_color_button_normal'))147+ .backgroundColor(PlaylistConstants.GRID_CARD_MORE_BACKGROUND)
146 .margin({148 .margin({
147 top: PlaylistConstants.GRID_CARD_MORE_MARGIN,149 top: PlaylistConstants.GRID_CARD_MORE_MARGIN,
148 right: PlaylistConstants.GRID_CARD_MORE_MARGIN150 right: PlaylistConstants.GRID_CARD_MORE_MARGIN
@@ -102,9 +102,10 @@ export class PlaylistConstants {
102 /** 网格卡无封面:浅灰蓝底(参考设计稿) */102 /** 网格卡无封面:浅灰蓝底(参考设计稿) */
103 static readonly GRID_CARD_COVER_PLACEHOLDER_BACKGROUND: string = '#D6E2EE';103 static readonly GRID_CARD_COVER_PLACEHOLDER_BACKGROUND: string = '#D6E2EE';
104 static readonly GRID_CARD_COVER_PLACEHOLDER_ICON_COLOR: string = '#A8B8C8';104 static readonly GRID_CARD_COVER_PLACEHOLDER_ICON_COLOR: string = '#A8B8C8';
105- /** 网格卡更多按钮 */105+ /** 网格卡更多按钮:深色半透明底 + 白色图标,压在封面上仍可辨认 */
106 static readonly GRID_CARD_MORE_ICON_SIZE: number = 20;106 static readonly GRID_CARD_MORE_ICON_SIZE: number = 20;
107- static readonly GRID_CARD_MORE_ICON_COLOR: Resource = AppThemeConstants.TEXT_PRIMARY;107+ static readonly GRID_CARD_MORE_ICON_COLOR: string = '#FFFFFFFF';
108+ static readonly GRID_CARD_MORE_BACKGROUND: string = 'rgba(128, 128, 128, 0.72)';
108 static readonly GRID_CARD_MORE_HIT_SIZE: number = 32;109 static readonly GRID_CARD_MORE_HIT_SIZE: number = 32;
109 static readonly GRID_CARD_MORE_MARGIN: number = 4;110 static readonly GRID_CARD_MORE_MARGIN: number = 4;
110 /** 歌单列表行无封面占位:浅灰底圆角框(多选页) */111 /** 歌单列表行无封面占位:浅灰底圆角框(多选页) */