已开启
feat: 媒体列表与歌单失效项清理优化 #22
若水随风创建于 8 天前
feat: 媒体列表与歌单失效项清理优化 #22
已开启
共 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 通知 UI | 329 | + 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 显示,不等待文件系统扫描+fillDuration | 350 | + 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); |
| @@ -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'; |
| 32 | import { MediaDbManager, PlaylistRecord } from '@ohos/common/src/main/ets/persistence/rdb/MediaDbManager'; | 32 | import { MediaDbManager, PlaylistRecord } from '@ohos/common/src/main/ets/persistence/rdb/MediaDbManager'; |
| 33 | import { AudioItem } from '@ohos/common/src/main/ets/model/AudioItem'; | 33 | import { 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'; | ||
| 34 | import { common } from '@kit.AbilityKit'; | 36 | import { common } from '@kit.AbilityKit'; |
| 35 | import { promptAction } from '@kit.ArkUI'; | 37 | import { promptAction } from '@kit.ArkUI'; |
| 36 | import { HomeViewModel } from './HomeViewModel'; | 38 | import { 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 | ||
| 16 | import { promptAction } from '@kit.ArkUI'; | 16 | import { 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 | */ |
| 24 | export function showMediaSourceFailureToast(state: MediaSourceAccessState): void { | 24 | export 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_MARGIN | 150 | 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 | /** 歌单列表行无封面占位:浅灰底圆角框(多选页) */ |