Created
August 23, 2018 00:28
-
-
Save NeoLSN/1639b275e26659c6b3d641c580b00a2c to your computer and use it in GitHub Desktop.
Image Cache
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) { } | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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