|
@@ -12,7 +12,7 @@ import { FileInfo } from '../viewmodel/FileInfo';
|
|
|
import { emitter } from '@kit.BasicServicesKit';
|
|
import { emitter } from '@kit.BasicServicesKit';
|
|
|
import { EventConstants } from '../common/constants/EventConstants';
|
|
import { EventConstants } from '../common/constants/EventConstants';
|
|
|
import { LazyDataSource } from '../common/util/LazyDataSource';
|
|
import { LazyDataSource } from '../common/util/LazyDataSource';
|
|
|
-import { ArrayUtil, PreferencesUtil, StrUtil, ToastUtil } from '@pura/harmony-utils';
|
|
|
|
|
|
|
+import { ArrayUtil, FileUtil, MD5, PreferencesUtil, StrUtil, ToastUtil } from '@pura/harmony-utils';
|
|
|
import { DialogHelper } from '@pura/harmony-dialog';
|
|
import { DialogHelper } from '@pura/harmony-dialog';
|
|
|
import { ButtonFancyModifier,
|
|
import { ButtonFancyModifier,
|
|
|
MenuModifier,
|
|
MenuModifier,
|
|
@@ -24,6 +24,10 @@ import { SettingPage } from './SettingPage';
|
|
|
import { getCloudDiskIcon } from '../dialog/RemoteDriveAccountDialog';
|
|
import { getCloudDiskIcon } from '../dialog/RemoteDriveAccountDialog';
|
|
|
import { CreateFolderDialog } from '../dialog/CreateFolderDialog';
|
|
import { CreateFolderDialog } from '../dialog/CreateFolderDialog';
|
|
|
import { UploadMusicPage } from './UploadMusicPage';
|
|
import { UploadMusicPage } from './UploadMusicPage';
|
|
|
|
|
+import { FFmpeg } from '@sj/ffmpeg';
|
|
|
|
|
+import FileManager from '../common/util/FileManager';
|
|
|
|
|
+import { fileIo, fileUri } from '@kit.CoreFileKit';
|
|
|
|
|
+import { setVideoUrlForSong } from '../common/util/RemotePlayerUtil';
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 歌单播放事件数据
|
|
* 歌单播放事件数据
|
|
@@ -45,11 +49,20 @@ interface WebDavMetadataUpdatePayload {
|
|
|
artist?: string;
|
|
artist?: string;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+interface RemoteThumbSource {
|
|
|
|
|
+ playUrl: string;
|
|
|
|
|
+ headers: Map<string, string>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
const TAG = 'heanup WebDavMainPage';
|
|
const TAG = 'heanup WebDavMainPage';
|
|
|
|
|
+const REMOTE_THUMB_CACHE_DIR: string = 'remote_thumbs';
|
|
|
|
|
+const REMOTE_THUMB_MAX_TASK_COUNT: number = 36;
|
|
|
|
|
+const REMOTE_THUMB_CAPTURE_SECONDS: string = '1.2';
|
|
|
|
|
|
|
|
// WebDAV歌曲数据全局内存存储
|
|
// WebDAV歌曲数据全局内存存储
|
|
|
let globalWebdavVideoItems: VideoItem[] = [];
|
|
let globalWebdavVideoItems: VideoItem[] = [];
|
|
|
let globalWebdavCurrentPlayIndex: number = 0;
|
|
let globalWebdavCurrentPlayIndex: number = 0;
|
|
|
|
|
+let globalRemoteThumbFfmpegQueue: Promise<void> = Promise.resolve();
|
|
|
|
|
|
|
|
// 导出函数供LocalMusic访问
|
|
// 导出函数供LocalMusic访问
|
|
|
export function getWebdavVideoItems(): VideoItem[] {
|
|
export function getWebdavVideoItems(): VideoItem[] {
|
|
@@ -121,9 +134,23 @@ export struct WebDavMainPage {
|
|
|
@State isAllSelected: boolean = false
|
|
@State isAllSelected: boolean = false
|
|
|
@State isDeletingSelection: boolean = false
|
|
@State isDeletingSelection: boolean = false
|
|
|
private readonly metadataUpdateEvent: emitter.InnerEvent = { eventId: EventConstants.EVENT_WEBDAV_METADATA_UPDATED };
|
|
private readonly metadataUpdateEvent: emitter.InnerEvent = { eventId: EventConstants.EVENT_WEBDAV_METADATA_UPDATED };
|
|
|
|
|
+ private thumbnailTaskToken: number = 0;
|
|
|
|
|
+ private thumbnailRunningKeys: Set<string> = new Set<string>();
|
|
|
|
|
+
|
|
|
|
|
+ private async runSerializedThumbnailFfmpeg(task: () => Promise<void>): Promise<void> {
|
|
|
|
|
+ const nextTask: Promise<void> = globalRemoteThumbFfmpegQueue.then(async (): Promise<void> => {
|
|
|
|
|
+ await task();
|
|
|
|
|
+ }, async (): Promise<void> => {
|
|
|
|
|
+ await task();
|
|
|
|
|
+ });
|
|
|
|
|
+ globalRemoteThumbFfmpegQueue = nextTask.catch((): void => {});
|
|
|
|
|
+ await nextTask;
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
async onSwitchAccount(){
|
|
async onSwitchAccount(){
|
|
|
console.log('heanup 切换账户:', this.selectedAccount.name);
|
|
console.log('heanup 切换账户:', this.selectedAccount.name);
|
|
|
|
|
+ this.thumbnailTaskToken += 1;
|
|
|
|
|
+ this.thumbnailRunningKeys.clear();
|
|
|
this.songs = [];
|
|
this.songs = [];
|
|
|
this.visibleFoldersState = [];
|
|
this.visibleFoldersState = [];
|
|
|
this.updateListData(this.songs)
|
|
this.updateListData(this.songs)
|
|
@@ -157,6 +184,393 @@ export struct WebDavMainPage {
|
|
|
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private buildThumbnailIdentity(item: VideoItem): string {
|
|
|
|
|
+ const accountId: string = item.webdav_account_id ? item.webdav_account_id : '';
|
|
|
|
|
+ const relPath: string = item.remote_rel_path ? item.remote_rel_path : '';
|
|
|
|
|
+ return `${item.type}:${accountId}:${relPath}:${item.filePath}`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async ensureThumbnailCacheDir(): Promise<string> {
|
|
|
|
|
+ const context = getContext(this);
|
|
|
|
|
+ const cacheDir: string = `${context.filesDir}/${REMOTE_THUMB_CACHE_DIR}`;
|
|
|
|
|
+ await FileManager.createDir(cacheDir);
|
|
|
|
|
+ return cacheDir;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async resolveRemoteThumbPath(item: VideoItem): Promise<string> {
|
|
|
|
|
+ const cacheDir: string = await this.ensureThumbnailCacheDir();
|
|
|
|
|
+ const identity: string = this.buildThumbnailIdentity(item);
|
|
|
|
|
+ const hashName: string = MD5.digestSync(identity);
|
|
|
|
|
+ return `${cacheDir}/${hashName}.jpg`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildFfmpegHeadersArg(headers: Map<string, string>): string {
|
|
|
|
|
+ if (!headers || headers.size <= 0) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ const lines: string[] = [];
|
|
|
|
|
+ headers.forEach((value: string, key: string): void => {
|
|
|
|
|
+ if (StrUtil.isEmpty(key) || StrUtil.isEmpty(value)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ lines.push(`${key}: ${value}`);
|
|
|
|
|
+ });
|
|
|
|
|
+ if (lines.length <= 0) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ return `${lines.join('\r\n')}\r\n`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildRemoteThumbFfmpegCmd(playUrl: string, headersArg: string, outputPath: string): string[] {
|
|
|
|
|
+ const commands: string[] = ['ffmpeg', '-y', '-ss', REMOTE_THUMB_CAPTURE_SECONDS];
|
|
|
|
|
+ const networkArgs: string[] = this.buildRemoteFfmpegNetworkArgs(playUrl);
|
|
|
|
|
+ for (let index: number = 0; index < networkArgs.length; index += 1) {
|
|
|
|
|
+ commands.push(networkArgs[index]);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StrUtil.isNotEmpty(headersArg)) {
|
|
|
|
|
+ commands.push('-headers', headersArg);
|
|
|
|
|
+ }
|
|
|
|
|
+ commands.push(
|
|
|
|
|
+ '-i', playUrl,
|
|
|
|
|
+ '-frames:v', '1',
|
|
|
|
|
+ '-vf', 'scale=320:-2',
|
|
|
|
|
+ '-q:v', '4',
|
|
|
|
|
+ outputPath
|
|
|
|
|
+ );
|
|
|
|
|
+ return commands;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildRemoteAudioCoverFfmpegCmd(playUrl: string, headersArg: string, outputPath: string): string[] {
|
|
|
|
|
+ const commands: string[] = ['ffmpeg', '-y'];
|
|
|
|
|
+ const networkArgs: string[] = this.buildRemoteFfmpegNetworkArgs(playUrl);
|
|
|
|
|
+ for (let index: number = 0; index < networkArgs.length; index += 1) {
|
|
|
|
|
+ commands.push(networkArgs[index]);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StrUtil.isNotEmpty(headersArg)) {
|
|
|
|
|
+ commands.push('-headers', headersArg);
|
|
|
|
|
+ }
|
|
|
|
|
+ commands.push(
|
|
|
|
|
+ '-i', playUrl,
|
|
|
|
|
+ '-map', '0:v:0',
|
|
|
|
|
+ '-frames:v', '1',
|
|
|
|
|
+ '-q:v', '4',
|
|
|
|
|
+ outputPath
|
|
|
|
|
+ );
|
|
|
|
|
+ return commands;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildRemoteFfmpegNetworkArgs(playUrl: string): string[] {
|
|
|
|
|
+ if (StrUtil.isEmpty(playUrl)) {
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+ const lowerPlayUrl: string = playUrl.toLowerCase();
|
|
|
|
|
+ if (!lowerPlayUrl.startsWith('http://') && !lowerPlayUrl.startsWith('https://')) {
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+ return ['-rw_timeout', '15000000', '-probesize', '1048576', '-analyzeduration', '2000000'];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private sanitizeFfmpegCommands(commands: string[]): string[] {
|
|
|
|
|
+ const safeCommands: string[] = [];
|
|
|
|
|
+ for (let index: number = 0; index < commands.length; index++) {
|
|
|
|
|
+ const value: string = commands[index];
|
|
|
|
|
+ if (value === undefined || value === null) {
|
|
|
|
|
+ Logger.warn(TAG, `缩略图命令存在空参数 index=${index}`);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ const text: string = `${value}`;
|
|
|
|
|
+ if (text.length <= 0) {
|
|
|
|
|
+ Logger.warn(TAG, `缩略图命令存在空字符串参数 index=${index}`);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ safeCommands.push(text);
|
|
|
|
|
+ }
|
|
|
|
|
+ return safeCommands;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private normalizeThumbImageSource(path: string): string {
|
|
|
|
|
+ if (StrUtil.isEmpty(path)) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('file://')) {
|
|
|
|
|
+ return path;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (path.startsWith('/')) {
|
|
|
|
|
+ return fileUri.getUriFromPath(path);
|
|
|
|
|
+ }
|
|
|
|
|
+ return path;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private normalizeThumbLocalPath(path: string): string {
|
|
|
|
|
+ if (StrUtil.isEmpty(path)) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ if (path.startsWith('file://')) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return new fileUri.FileUri(path).path;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err: Error = error as Error;
|
|
|
|
|
+ Logger.warn(TAG, `缩略图URI转路径失败 uri=${path}, error=${err.message}`);
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return path;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private isThumbCacheValid(localPath: string): boolean {
|
|
|
|
|
+ if (StrUtil.isEmpty(localPath)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!FileUtil.accessSync(localPath)) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const stat: fileIo.Stat = fileIo.statSync(localPath);
|
|
|
|
|
+ if (stat.size <= 1024) {
|
|
|
|
|
+ Logger.warn(TAG, `缩略图缓存文件过小,判定无效 path=${localPath}, size=${stat.size}`);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (this.isImageMagicValid(localPath)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ Logger.warn(TAG, `缩略图缓存文件头无效,判定无效 path=${localPath}, size=${stat.size}`);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err: Error = error as Error;
|
|
|
|
|
+ Logger.warn(TAG, `缩略图缓存校验失败 path=${localPath}, error=${err.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private isImageMagicValid(localPath: string): boolean {
|
|
|
|
|
+ let file: fileIo.File | undefined = undefined;
|
|
|
|
|
+ try {
|
|
|
|
|
+ file = fileIo.openSync(localPath, fileIo.OpenMode.READ_ONLY);
|
|
|
|
|
+ const headerBuffer: ArrayBuffer = new ArrayBuffer(12);
|
|
|
|
|
+ const readLen: number = fileIo.readSync(file.fd, headerBuffer);
|
|
|
|
|
+ if (readLen < 4) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ const bytes: Uint8Array = new Uint8Array(headerBuffer);
|
|
|
|
|
+ const isJpg: boolean = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF;
|
|
|
|
|
+ const isPng: boolean = readLen >= 8 &&
|
|
|
|
|
+ bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47 &&
|
|
|
|
|
+ bytes[4] === 0x0D && bytes[5] === 0x0A && bytes[6] === 0x1A && bytes[7] === 0x0A;
|
|
|
|
|
+ const isGif: boolean = readLen >= 4 &&
|
|
|
|
|
+ bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38;
|
|
|
|
|
+ const isBmp: boolean = bytes[0] === 0x42 && bytes[1] === 0x4D;
|
|
|
|
|
+ const isWebp: boolean = readLen >= 12 &&
|
|
|
|
|
+ bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
|
|
|
|
|
+ bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
|
|
|
|
|
+ return isJpg || isPng || isGif || isBmp || isWebp;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err: Error = error as Error;
|
|
|
|
|
+ Logger.warn(TAG, `缩略图文件头校验失败 path=${localPath}, error=${err.message}`);
|
|
|
|
|
+ return false;
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ if (file !== undefined) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ fileIo.closeSync(file);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err: Error = error as Error;
|
|
|
|
|
+ Logger.warn(TAG, `关闭缩略图文件失败 path=${localPath}, error=${err.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private removeInvalidThumbCache(localPath: string): void {
|
|
|
|
|
+ if (StrUtil.isEmpty(localPath) || !FileUtil.accessSync(localPath)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ fileIo.unlinkSync(localPath);
|
|
|
|
|
+ Logger.info(TAG, `已删除无效缩略图缓存 path=${localPath}`);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err: Error = error as Error;
|
|
|
|
|
+ Logger.warn(TAG, `删除无效缩略图缓存失败 path=${localPath}, error=${err.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private resolveFileNameForTypeCheck(item: VideoItem): string {
|
|
|
|
|
+ const rawName: string = item.fileName ?? item.remote_rel_path ?? item.filePath;
|
|
|
|
|
+ if (StrUtil.isEmpty(rawName)) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ const hashIndex = rawName.indexOf('#');
|
|
|
|
|
+ const queryIndex = rawName.indexOf('?');
|
|
|
|
|
+ let endIndex = rawName.length;
|
|
|
|
|
+ if (queryIndex >= 0) {
|
|
|
|
|
+ endIndex = queryIndex;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (hashIndex >= 0 && hashIndex < endIndex) {
|
|
|
|
|
+ endIndex = hashIndex;
|
|
|
|
|
+ }
|
|
|
|
|
+ return rawName.substring(0, endIndex);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private isVideoMediaItem(item: VideoItem): boolean {
|
|
|
|
|
+ if (item.mimeType && item.mimeType.toLowerCase().startsWith('video/')) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ const fileNameForCheck: string = this.resolveFileNameForTypeCheck(item);
|
|
|
|
|
+ return Utility.isVideoByExtension(fileNameForCheck);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private refreshThumbItem(item: VideoItem): void {
|
|
|
|
|
+ const dataIndex: number = this.dataSource.dataArray.indexOf(item);
|
|
|
|
|
+ if (dataIndex >= 0) {
|
|
|
|
|
+ this.dataSource.notifyDataChange(dataIndex);
|
|
|
|
|
+ }
|
|
|
|
|
+ this.listRefreshKey += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async prepareRemoteThumbSource(item: VideoItem): Promise<RemoteThumbSource> {
|
|
|
|
|
+ let playUrl: string = item.filePath;
|
|
|
|
|
+ if (StrUtil.isNotEmpty(playUrl)) {
|
|
|
|
|
+ const lowerPath = playUrl.toLowerCase();
|
|
|
|
|
+ const isDirectPath = lowerPath.startsWith('http://') || lowerPath.startsWith('https://') ||
|
|
|
|
|
+ lowerPath.startsWith('/') || lowerPath.startsWith('file://');
|
|
|
|
|
+ if (!isDirectPath) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ playUrl = await setVideoUrlForSong(item, {
|
|
|
|
|
+ extractCover: false,
|
|
|
|
|
+ extractLyric: false,
|
|
|
|
|
+ extractAudioInfo: false
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ Logger.warn(TAG, `解析缩略图播放地址失败 name=${item.name}, error=${(error as Error).message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const headers: Map<string, string> = await this.webdavManager.buildHttpHeadersWithAccountId(item, playUrl);
|
|
|
|
|
+ const source: RemoteThumbSource = {
|
|
|
|
|
+ playUrl: playUrl,
|
|
|
|
|
+ headers: headers
|
|
|
|
|
+ };
|
|
|
|
|
+ return source;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async tryResolveRemoteMusicCover(item: VideoItem, token: number): Promise<void> {
|
|
|
|
|
+ const coverPath: string = await this.resolveRemoteThumbPath(item);
|
|
|
|
|
+ if (this.isThumbCacheValid(coverPath)) {
|
|
|
|
|
+ item.pixelMapPath = this.normalizeThumbImageSource(coverPath);
|
|
|
|
|
+ this.refreshThumbItem(item);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.removeInvalidThumbCache(coverPath);
|
|
|
|
|
+ const source: RemoteThumbSource = await this.prepareRemoteThumbSource(item);
|
|
|
|
|
+ if (StrUtil.isEmpty(source.playUrl)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const headersArg: string = this.buildFfmpegHeadersArg(source.headers);
|
|
|
|
|
+ const rawCommands: string[] = this.buildRemoteAudioCoverFfmpegCmd(source.playUrl, headersArg, coverPath);
|
|
|
|
|
+ const commands: string[] = this.sanitizeFfmpegCommands(rawCommands);
|
|
|
|
|
+ if (commands.length < 2) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ await this.runSerializedThumbnailFfmpeg(async (): Promise<void> => {
|
|
|
|
|
+ if (token !== this.thumbnailTaskToken) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ await FFmpeg.execute(commands);
|
|
|
|
|
+ });
|
|
|
|
|
+ if (token !== this.thumbnailTaskToken) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (this.isThumbCacheValid(coverPath)) {
|
|
|
|
|
+ item.pixelMapPath = this.normalizeThumbImageSource(coverPath);
|
|
|
|
|
+ this.refreshThumbItem(item);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async generateRemoteVideoThumb(item: VideoItem, token: number): Promise<void> {
|
|
|
|
|
+ if (token !== this.thumbnailTaskToken) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const identity: string = this.buildThumbnailIdentity(item);
|
|
|
|
|
+ if (this.thumbnailRunningKeys.has(identity)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.thumbnailRunningKeys.add(identity);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const isVideoFile: boolean = this.isVideoMediaItem(item);
|
|
|
|
|
+ if (!isVideoFile) {
|
|
|
|
|
+ await this.tryResolveRemoteMusicCover(item, token);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const thumbPath: string = await this.resolveRemoteThumbPath(item);
|
|
|
|
|
+ if (this.isThumbCacheValid(thumbPath)) {
|
|
|
|
|
+ item.pixelMapPath = this.normalizeThumbImageSource(thumbPath);
|
|
|
|
|
+ this.refreshThumbItem(item);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.removeInvalidThumbCache(thumbPath);
|
|
|
|
|
+ const source = await this.prepareRemoteThumbSource(item);
|
|
|
|
|
+ if (StrUtil.isEmpty(source.playUrl)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const headersArg: string = this.buildFfmpegHeadersArg(source.headers);
|
|
|
|
|
+ const rawCommands: string[] = this.buildRemoteThumbFfmpegCmd(source.playUrl, headersArg, thumbPath);
|
|
|
|
|
+ const commands: string[] = this.sanitizeFfmpegCommands(rawCommands);
|
|
|
|
|
+ if (commands.length < 2) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ await this.runSerializedThumbnailFfmpeg(async (): Promise<void> => {
|
|
|
|
|
+ if (token !== this.thumbnailTaskToken) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ await FFmpeg.execute(commands);
|
|
|
|
|
+ });
|
|
|
|
|
+ if (token !== this.thumbnailTaskToken) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (this.isThumbCacheValid(thumbPath)) {
|
|
|
|
|
+ item.pixelMapPath = this.normalizeThumbImageSource(thumbPath);
|
|
|
|
|
+ this.refreshThumbItem(item);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err: Error = error as Error;
|
|
|
|
|
+ Logger.info(TAG, `远程缩略图/封面生成失败 name=${item.name}, error=${err.message}`);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.thumbnailRunningKeys.delete(identity);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private scheduleRemoteThumbPrefetch(): void {
|
|
|
|
|
+ const token: number = this.thumbnailTaskToken + 1;
|
|
|
|
|
+ this.thumbnailTaskToken = token;
|
|
|
|
|
+ this.thumbnailRunningKeys.clear();
|
|
|
|
|
+ const mediaItems: VideoItem[] = this.songs.slice();
|
|
|
|
|
+ if (mediaItems.length <= 0) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const limitCount: number = Math.min(mediaItems.length, REMOTE_THUMB_MAX_TASK_COUNT);
|
|
|
|
|
+ const targetItems: VideoItem[] = mediaItems.slice(0, limitCount);
|
|
|
|
|
+ setTimeout((): void => {
|
|
|
|
|
+ void this.runRemoteThumbPrefetchQueue(targetItems, token);
|
|
|
|
|
+ }, 120);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async runRemoteThumbPrefetchQueue(items: VideoItem[], token: number): Promise<void> {
|
|
|
|
|
+ for (let index: number = 0; index < items.length; index += 1) {
|
|
|
|
|
+ if (token !== this.thumbnailTaskToken) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const item: VideoItem = items[index];
|
|
|
|
|
+ if (StrUtil.isNotEmpty(item.pixelMapPath)) {
|
|
|
|
|
+ const currentThumbRef: string = item.pixelMapPath ? item.pixelMapPath : '';
|
|
|
|
|
+ if (currentThumbRef.startsWith('http')) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ const currentThumbPath: string = this.normalizeThumbLocalPath(currentThumbRef);
|
|
|
|
|
+ if (StrUtil.isNotEmpty(currentThumbPath) && this.isThumbCacheValid(currentThumbPath)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ await this.generateRemoteVideoThumb(item, token);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// 更新可见文件夹列表
|
|
// 更新可见文件夹列表
|
|
|
private updateVisibleFolders(): void {
|
|
private updateVisibleFolders(): void {
|
|
|
try {
|
|
try {
|
|
@@ -398,6 +812,8 @@ export struct WebDavMainPage {
|
|
|
|
|
|
|
|
aboutToDisappear(): void {
|
|
aboutToDisappear(): void {
|
|
|
// 取消订阅
|
|
// 取消订阅
|
|
|
|
|
+ this.thumbnailTaskToken += 1;
|
|
|
|
|
+ this.thumbnailRunningKeys.clear();
|
|
|
this.webdavManager.unsubscribe(this.eventHandler);
|
|
this.webdavManager.unsubscribe(this.eventHandler);
|
|
|
emitter.off(EventConstants.EVENT_WEBDAV_METADATA_UPDATED);
|
|
emitter.off(EventConstants.EVENT_WEBDAV_METADATA_UPDATED);
|
|
|
}
|
|
}
|
|
@@ -416,6 +832,7 @@ export struct WebDavMainPage {
|
|
|
this.updateVisibleFolders();
|
|
this.updateVisibleFolders();
|
|
|
this.breadcrumbs = this.webdavManager.getBreadcrumbs();
|
|
this.breadcrumbs = this.webdavManager.getBreadcrumbs();
|
|
|
this.syncSelectionAfterRefresh();
|
|
this.syncSelectionAfterRefresh();
|
|
|
|
|
+ this.scheduleRemoteThumbPrefetch();
|
|
|
|
|
|
|
|
// promptAction.showToast({
|
|
// promptAction.showToast({
|
|
|
// message: '加载成功: ' + this.webDavFiles.filter(f => f.isDirectory).length + '个文件夹, ' + this.songs.length + '首歌曲'
|
|
// message: '加载成功: ' + this.webDavFiles.filter(f => f.isDirectory).length + '个文件夹, ' + this.songs.length + '首歌曲'
|