|
|
@@ -0,0 +1,205 @@
|
|
|
+import { common } from '@kit.AbilityKit';
|
|
|
+import { fileIo, fileUri } from '@kit.CoreFileKit';
|
|
|
+import { image } from '@kit.ImageKit';
|
|
|
+import { FileUtil, MD5, StrUtil } from '@pura/harmony-utils';
|
|
|
+import Logger from './Logger';
|
|
|
+
|
|
|
+interface CoverThumbJob {
|
|
|
+ cacheKey: string;
|
|
|
+ sourcePath: string;
|
|
|
+ targetSize: number;
|
|
|
+}
|
|
|
+
|
|
|
+interface FileHandleLike {
|
|
|
+ fd: number;
|
|
|
+}
|
|
|
+
|
|
|
+const TAG = 'CoverThumbCache';
|
|
|
+
|
|
|
+export class CoverThumbCache {
|
|
|
+ private context?: common.UIAbilityContext;
|
|
|
+ private thumbMap: Map<string, string> = new Map();
|
|
|
+ private pending: Set<string> = new Set();
|
|
|
+ private queue: Array<CoverThumbJob> = [];
|
|
|
+ private workerCount: number = 0;
|
|
|
+ private refreshTimer: number = 0;
|
|
|
+ private notifyThumbReady?: () => void;
|
|
|
+ private readonly maxWorkers: number;
|
|
|
+ private readonly maxEntries: number;
|
|
|
+ private dirPath: string = '';
|
|
|
+ private dirReady: boolean = false;
|
|
|
+
|
|
|
+ constructor(maxWorkers: number = 1, maxEntries: number = 420) {
|
|
|
+ this.maxWorkers = Math.max(1, maxWorkers);
|
|
|
+ this.maxEntries = Math.max(64, maxEntries);
|
|
|
+ }
|
|
|
+
|
|
|
+ setContext(context: common.UIAbilityContext): void {
|
|
|
+ this.context = context;
|
|
|
+ }
|
|
|
+
|
|
|
+ setOnThumbReady(notify: () => void): void {
|
|
|
+ this.notifyThumbReady = notify;
|
|
|
+ }
|
|
|
+
|
|
|
+ dispose(): void {
|
|
|
+ if (this.refreshTimer) {
|
|
|
+ clearTimeout(this.refreshTimer);
|
|
|
+ this.refreshTimer = 0;
|
|
|
+ }
|
|
|
+ this.queue = [];
|
|
|
+ this.pending.clear();
|
|
|
+ this.workerCount = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ clearPending(): void {
|
|
|
+ this.queue = [];
|
|
|
+ this.pending.clear();
|
|
|
+ }
|
|
|
+
|
|
|
+ isRemoteImageSource(sourceUri: string): boolean {
|
|
|
+ const lower = sourceUri.toLowerCase();
|
|
|
+ return lower.startsWith('http://') || lower.startsWith('https://');
|
|
|
+ }
|
|
|
+
|
|
|
+ getThumbUri(sourceUri: string, targetSize: number): string | undefined {
|
|
|
+ return this.thumbMap.get(this.buildCacheKey(sourceUri, targetSize));
|
|
|
+ }
|
|
|
+
|
|
|
+ ensureThumbRequested(sourceUri: string, targetSize: number): void {
|
|
|
+ if (!this.context || StrUtil.isEmpty(sourceUri) || this.isRemoteImageSource(sourceUri)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const cacheKey = this.buildCacheKey(sourceUri, targetSize);
|
|
|
+ if (this.thumbMap.has(cacheKey) || this.pending.has(cacheKey)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const sourcePath = this.normalizeLocalImagePath(sourceUri);
|
|
|
+ if (StrUtil.isEmpty(sourcePath) || !FileUtil.accessSync(sourcePath)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.pending.add(cacheKey);
|
|
|
+ if (this.queue.length > 220) {
|
|
|
+ this.queue.shift();
|
|
|
+ }
|
|
|
+ this.queue.push({
|
|
|
+ cacheKey,
|
|
|
+ sourcePath,
|
|
|
+ targetSize
|
|
|
+ });
|
|
|
+ this.processQueue();
|
|
|
+ }
|
|
|
+
|
|
|
+ private buildCacheKey(sourceUri: string, targetSize: number): string {
|
|
|
+ return `${sourceUri}|${targetSize}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ private normalizeLocalImagePath(sourceUri: string): string {
|
|
|
+ if (StrUtil.isEmpty(sourceUri)) {
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+ if (sourceUri.startsWith('file://')) {
|
|
|
+ let normalized = sourceUri.substring('file://'.length);
|
|
|
+ try {
|
|
|
+ normalized = decodeURIComponent(normalized);
|
|
|
+ } catch (_e) {
|
|
|
+ }
|
|
|
+ return normalized;
|
|
|
+ }
|
|
|
+ return sourceUri;
|
|
|
+ }
|
|
|
+
|
|
|
+ private ensureThumbDir(): string {
|
|
|
+ if (this.dirReady && StrUtil.isNotEmpty(this.dirPath)) {
|
|
|
+ return this.dirPath;
|
|
|
+ }
|
|
|
+ const nextDir = this.context!.filesDir + FileUtil.separator + 'cover_thumb_cache';
|
|
|
+ if (!FileUtil.accessSync(nextDir)) {
|
|
|
+ fileIo.mkdirSync(nextDir);
|
|
|
+ }
|
|
|
+ this.dirPath = nextDir;
|
|
|
+ this.dirReady = true;
|
|
|
+ return nextDir;
|
|
|
+ }
|
|
|
+
|
|
|
+ private processQueue(): void {
|
|
|
+ if (this.workerCount >= this.maxWorkers) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const job = this.queue.shift();
|
|
|
+ if (!job) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.workerCount++;
|
|
|
+ void this.buildThumb(job).then((thumbUri?: string) => {
|
|
|
+ if (StrUtil.isNotEmpty(thumbUri)) {
|
|
|
+ this.thumbMap.set(job.cacheKey, thumbUri!);
|
|
|
+ while (this.thumbMap.size > this.maxEntries) {
|
|
|
+ const oldest = this.thumbMap.keys().next().value as string | undefined;
|
|
|
+ if (!oldest) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ this.thumbMap.delete(oldest);
|
|
|
+ }
|
|
|
+ this.scheduleNotify();
|
|
|
+ }
|
|
|
+ }).catch((err: Error) => {
|
|
|
+ Logger.warn(TAG, `buildThumb failed: ${err.message}`);
|
|
|
+ }).finally(() => {
|
|
|
+ this.pending.delete(job.cacheKey);
|
|
|
+ this.workerCount = Math.max(this.workerCount - 1, 0);
|
|
|
+ this.processQueue();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private scheduleNotify(): void {
|
|
|
+ if (this.refreshTimer) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.refreshTimer = setTimeout(() => {
|
|
|
+ this.refreshTimer = 0;
|
|
|
+ this.notifyThumbReady?.();
|
|
|
+ }, 150);
|
|
|
+ }
|
|
|
+
|
|
|
+ private async buildThumb(job: CoverThumbJob): Promise<string | undefined> {
|
|
|
+ let srcFile: object | undefined;
|
|
|
+ let outFile: object | undefined;
|
|
|
+ try {
|
|
|
+ const thumbDir = this.ensureThumbDir();
|
|
|
+ const hashName = await MD5.digestSync(`${job.cacheKey}|thumb`);
|
|
|
+ const thumbPath = `${thumbDir}${FileUtil.separator}${hashName}.jpg`;
|
|
|
+ if (FileUtil.accessSync(thumbPath)) {
|
|
|
+ return fileUri.getUriFromPath(thumbPath);
|
|
|
+ }
|
|
|
+
|
|
|
+ srcFile = fileIo.openSync(job.sourcePath, fileIo.OpenMode.READ_ONLY);
|
|
|
+ const imageSource = image.createImageSource((srcFile as FileHandleLike).fd);
|
|
|
+ const decodeOpts: image.DecodingOptions = { editable: false };
|
|
|
+ const pixelMap = await imageSource.createPixelMap(decodeOpts);
|
|
|
+ const ratio = job.targetSize <= 160 ? 0.26 : (job.targetSize <= 220 ? 0.38 : 0.50);
|
|
|
+ try {
|
|
|
+ await pixelMap.scale(ratio, ratio);
|
|
|
+ } catch (_scaleErr) {
|
|
|
+ }
|
|
|
+
|
|
|
+ const imagePacker = image.createImagePacker();
|
|
|
+ const packOpt: image.PackingOption = { format: 'image/jpeg', quality: 68 };
|
|
|
+ const imageData = await imagePacker.packing(pixelMap, packOpt);
|
|
|
+ outFile = fileIo.openSync(thumbPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
|
|
|
+ await fileIo.write((outFile as FileHandleLike).fd, imageData);
|
|
|
+ return fileUri.getUriFromPath(thumbPath);
|
|
|
+ } catch (err) {
|
|
|
+ const error = err as Error;
|
|
|
+ Logger.warn(TAG, `buildThumb error: ${error.message}`);
|
|
|
+ return undefined;
|
|
|
+ } finally {
|
|
|
+ if (srcFile) {
|
|
|
+ fileIo.closeSync((srcFile as FileHandleLike).fd);
|
|
|
+ }
|
|
|
+ if (outFile) {
|
|
|
+ fileIo.closeSync((outFile as FileHandleLike).fd);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|