Skip to content

Instantly share code, notes, and snippets.

@NeoLSN
Created August 23, 2018 00:28
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save NeoLSN/1639b275e26659c6b3d641c580b00a2c to your computer and use it in GitHub Desktop.
Save NeoLSN/1639b275e26659c6b3d641c580b00a2c to your computer and use it in GitHub Desktop.
Image Cache
import {
ModuleWithProviders,
NgModule,
Optional,
SkipSelf
} from '@angular/core';
import { File } from '@ionic-native/file';
import { HTTP } from '@ionic-native/http';
import { ImageCacheService } from './image-cache.service';
import { ImageLazyLoadDirective } from './image-lazy-load.directive';
import { ResourceDownloader } from './resource-downloader';
const declarations = [
ImageLazyLoadDirective
];
@NgModule({
declarations: [...declarations],
exports: [...declarations]
})
export class ImageCacheModule {
public static forRoot(): ModuleWithProviders {
return {
ngModule: ImageCacheModule,
providers: [
ImageCacheService,
ResourceDownloader,
File,
HTTP,
]
};
}
constructor(@Optional() @SkipSelf() parentModule: ImageCacheModule) { }
}
import { Injectable } from '@angular/core';
import {
File,
FileEntry,
} from '@ionic-native/file';
import { normalizeURL } from 'ionic-angular';
import {
Observable,
ReplaySubject,
} from 'rxjs';
import {
first,
map,
mergeMap,
tap,
shareReplay,
} from 'rxjs/operators';
import { _throw } from 'rxjs/observable/throw';
import { defer } from 'rxjs/observable/defer';
import { fromPromise } from 'rxjs/observable/fromPromise';
import { of } from 'rxjs/observable/of';
import { ResourceDownloader } from './resource-downloader';
const CACHE_FOLDER: string = 'image-cache';
const EXTENSIONS_NAME: string = 'jpg';
const HTTP_REGEX = /^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/;
@Injectable()
export class ImageCacheService {
private initNotifier$: ReplaySubject<string> = new ReplaySubject(1);
private caches: Map<string, Observable<string>> = new Map<string, Observable<string>>();
constructor(
private file: File,
private downloader: ResourceDownloader,
) { }
public get notifier$(): Observable<string> {
return this.initNotifier$.asObservable();
}
public get cacheFolder() {
return `${this.file.cacheDirectory}${CACHE_FOLDER}/`;
}
public get isNativeAvailable(): boolean {
return File.installed();
}
public initImageCache(): Promise<string> {
const init$ = fromPromise<string>(this._init());
return init$
.pipe(
first(),
tap(() => this.initNotifier$.next('init'))
)
.toPromise();
}
private _init(): Promise<any> {
if (!this.isNativeAvailable) return Promise.resolve();
return this.cacheDirectoryExists(this.file.cacheDirectory)
.then(result => {
if (!result) this.file.createDir(this.file.cacheDirectory, CACHE_FOLDER, true);
})
.catch(() => this.file.createDir(this.file.cacheDirectory, CACHE_FOLDER, true));
}
private cacheDirectoryExists(directory: string): Promise<boolean> {
return this.file.checkDir(directory, CACHE_FOLDER);
}
public getImagePath(src: string): Observable<string> {
if (!src) {
return _throw(new Error('The image url provided was empty or invalid.'));
}
if (!this.isNativeAvailable || !this.isHttpUrl(src)) return of(src);
if (!this.caches.has(src)) {
const job = this.processImagePath(src);
this.caches.set(src, job);
}
return this.caches.get(src);
}
public isHttpUrl(url: string): boolean {
return HTTP_REGEX.test(url);
}
private processImagePath(src: string): Observable<string> {
return this.initNotifier$
.pipe(
mergeMap(() => this.isCached(src)),
mergeMap(isCached =>
isCached
? this.getLocalSource(src)
: this.getHttpSource(src)
),
map(url => normalizeURL(url)),
shareReplay(1)
);
}
private isCached(src: string): Observable<boolean> {
const fileName = this.createFileName(src);
return defer(() =>
this.file.checkFile(this.cacheFolder, fileName)
.catch(() => false)
);
}
private getLocalSource(src: string): Observable<string> {
return this.getCachedFileUrl(src);
}
private getHttpSource(src: string): Observable<string> {
return this.notifier$
.pipe(
mergeMap(() => this.cacheFile(src)),
mergeMap(() => this.getCachedFileUrl(src))
);
}
private getCachedFileUrl(src: string): Observable<string> {
const fileName = this.createFileName(src);
return defer(() =>
this.file.resolveLocalFilesystemUrl(`${this.cacheFolder}${fileName}`)
.then((tempFileEntry: FileEntry) => tempFileEntry.nativeURL)
);
}
private cacheFile(url: string): Observable<string> {
const fileName = this.createFileName(url);
const request = { url, filePath: `${this.cacheFolder}${fileName}` };
return this.downloader.download(request);
}
private createFileName(url: string): string {
return `${this.hashString(url).toString()}.${this.getExtensionFromFileName(url)}`;
}
private hashString(string: string): number {
let hash = 0;
if (string.length === 0) return hash;
for (let i = 0; i < string.length; i++) {
const char = string.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return hash;
}
private getExtensionFromFileName(filename: string) {
const ext = filename.match(/\.([^\./\?]+)($|\?)/);
return ext ? ext[1] : EXTENSIONS_NAME;
}
}
import {
Directive,
ElementRef,
Input,
OnInit,
OnDestroy,
Renderer2,
} from '@angular/core';
import {
Subject,
Subscription,
ReplaySubject,
} from 'rxjs';
import {
catchError,
debounceTime,
switchMap,
} from 'rxjs/operators';
import { of } from 'rxjs/observable/of';
import { ImageCacheService } from './image-cache.service';
@Directive({
selector: '[jy-lazy-load]'
})
export class ImageLazyLoadDirective implements OnInit, OnDestroy {
private _source: string;
private _placeholder: string = '';
get source(): string {
return this._source;
}
@Input('source')
set source(v: string) {
this._source = v || '';
this.subject.next('src');
}
get placeholder(): string {
return this._placeholder;
}
@Input('placeholder')
set placeholder(v: string) {
if (this.imgCacheService.isHttpUrl(v)) {
console.warn('Should NOT use the http/https url as the placeholder image url.');
}
this._placeholder = v || '';
this.subject.next('defaultSrc');
}
private subject: Subject<string>;
private cacheSubscription: Subscription;
constructor(
public el: ElementRef,
public imgCacheService: ImageCacheService,
public renderer: Renderer2,
) {
this.subject = new ReplaySubject<string>(1);
}
public ngOnInit(): void {
this.setImageSrc(this._placeholder);
// cache img and set the src to the img
this.cacheSubscription =
this.subject
.pipe(
debounceTime(100),
switchMap(() =>
this.imgCacheService.getImagePath(this._source)
.pipe(catchError(() => of(this._placeholder)))
)
)
.subscribe(value => this.setImageSrc(value));
}
private setImageSrc(src) {
if (!src) return;
const nativeElement: HTMLElement = this.el.nativeElement;
this.renderer.setAttribute(nativeElement, 'src', src);
}
public ngOnDestroy(): void {
this.cacheSubscription.unsubscribe();
this.subject.complete();
}
}
import { Injectable } from '@angular/core';
import { HTTP } from '@ionic-native/http';
import {
Observable,
ReplaySubject,
Subject,
} from 'rxjs';
import {
catchError,
delay,
map,
mergeMap,
tap,
} from 'rxjs/operators';
import { _throw } from 'rxjs/observable/throw';
import { defer } from 'rxjs/observable/defer';
import { of } from 'rxjs/observable/of';
const MAXIMUM_CONCURRENT: number = 5;
export interface DownloadRequest {
url: string;
filePath: string;
}
export interface DownloadJob {
request: DownloadRequest;
delay: number;
}
@Injectable()
export class ResourceDownloader {
private jobDispatcher: Subject<any> = new Subject<any>();
private workingTable: Map<string, Subject<any>> = new Map<string, Subject<any>>();
constructor(
private http: HTTP,
) {
this.jobDispatcher
.pipe(
mergeMap((job: DownloadJob) => of(job.request).pipe(delay(job.delay))),
mergeMap((request: DownloadRequest) => this.downloadResource(request), MAXIMUM_CONCURRENT)
)
.subscribe();
}
public download(request: DownloadRequest): Observable<any> {
if (!this.isValid(request)) return _throw(new Error('invalid source or destination'));
if (!this.workingTable.has(request.url)) {
const rpSubject: Subject<any> = new ReplaySubject<any>(1);
this.workingTable.set(request.url, rpSubject);
this.jobDispatcher.next({ request, delay: 0, });
}
const subject = this.workingTable.get(request.url);
return subject.asObservable();
}
private downloadResource(request: DownloadRequest): Observable<any> {
const { url, filePath } = request;
return defer(() => this.http.downloadFile(url, {}, {}, filePath))
.pipe(
tap(blob => this.dispatchResult(url, blob)),
map(() => url),
catchError(err => {
if (this.isNetworkError(err)) {
this.jobDispatcher.next({ request, delay: 10000, });
} else if (this.isServerError(err)) {
this.jobDispatcher.next({ request, delay: 30000, });
} else {
this.dispatchError(url, err);
}
return of(url);
})
);
}
private dispatchResult(src: string, result: any) {
const subject = this.workingTable.get(src);
subject.next(result);
}
private dispatchError(src: string, error) {
const subject = this.workingTable.get(src);
subject.error(error);
}
private isNetworkError(error): boolean {
const { status } = error;
return status >= 0 && status < 100;
}
private isServerError(error): boolean {
const { status } = error;
return status >= 500;
}
private isValid(request: DownloadRequest): boolean {
return !!(request && request.url && request.filePath);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment