mangadex/scripts/reader/ReaderPageModel.js

214 lines
7.2 KiB
JavaScript
Raw Normal View History

2021-03-19 16:06:32 -04:00
import EventEmitter from 'wolfy87-eventemitter'
export default class ReaderPageModel extends EventEmitter {
constructor(number, chapterId, url, fallbackURL) {
super()
this._number = number
this._chapter = chapterId
this._state = ReaderPageModel.STATE_WAIT
this._error = null
this._url = url
this._fallbackURL = fallbackURL
this._image = new Image()
this.addImageListeners()
}
get number() { return this._number }
get chapter() { return this._chapter }
get image() { return this._image }
get waiting() { return this.state === ReaderPageModel.STATE_WAIT }
get loading() { return this.state === ReaderPageModel.STATE_LOADING }
get loaded() { return this.state === ReaderPageModel.STATE_LOADED }
get hasError() { return this.state === ReaderPageModel.STATE_ERROR }
get isDone() { return this.loaded || this.hasError }
get error() { return this._error }
get state() { return this._state }
set state(v) {
this._state = v
this.trigger('statechange', [this])
}
get stateName() {
switch (this.state) {
case 0: return 'wait'
case 1: return 'loading'
case 2: return 'loaded'
case 3: return 'error'
}
}
load(breakCache = false) {
return new Promise((resolve, reject) => {
if (!breakCache && this.isDone) {
return resolve(this)
} else {
if (!this.loading) {
this._error = null
this.unload()
loadImage(this._url, this._fallbackURL).then(url => {
this._image.src = url
}).catch(e => {
this._error = e
this.state = ReaderPageModel.STATE_ERROR
reject(this)
})
this.state = ReaderPageModel.STATE_LOADING
}
this.once('statechange', () => {
switch (this.state) {
case ReaderPageModel.STATE_LOADED: return resolve(this)
case ReaderPageModel.STATE_ERROR: return reject(this)
}
})
}
})
}
unload() {
try {
if (this._image.src && this._image.src.startsWith('blob:')) {
URL.revokeObjectURL(this._image.src)
this._image.src = ''
}
} catch () {}
}
addImageListeners() {
const _errorHandler = () => {
this._error = new Error(`Image #${this.number} failed to load.`)
this.state = ReaderPageModel.STATE_ERROR
this.unload()
}
const _loadHandler = () => {
this.state = ReaderPageModel.STATE_LOADED
try { this._image.decode() } catch (e) { }
}
this._image.addEventListener('error', _errorHandler)
this._image.addEventListener('load', _loadHandler)
}
reload(breakCache = false) {
return this.load(this.hasError || breakCache)
}
static get STATE_WAIT() { return 0 }
static get STATE_LOADING() { return 1 }
static get STATE_LOADED() { return 2 }
static get STATE_ERROR() { return 3 }
}
async function loadImage(primaryURL, fallbackURL) {
try {
await caches.delete('mangadex_images')
} catch () {}
// Otherwise fetch the image and time how long it takes
let resp, timing, body
if (!fallbackURL || primaryURL === fallbackURL || !window.AbortController) {
// If we only have one URL, load it normally
;[resp, timing, body] = await fetchWithTiming(primaryURL, null)
} else {
// Otherwise things get weird. We want to race the requests
// but also get a working response if possible.
const primaryA = new AbortController()
const fallbackA = new AbortController()
// Make a promise for each URL, starting the fallback 5s after the primary
const primaryP = fetchWithTiming(primaryURL, primaryA.signal).catch(e => {
return [new Response(null, { status: 599 }), 0, 0]
})
const fallbackP = sleep(5000, fallbackA.signal)
.then(() => {
return fetchWithTiming(fallbackURL, fallbackA.signal)
})
.catch(e => {
return [new Response(null, { status: 599 }), 0, 0]
})
// Get the first response
;[resp, timing, body] = await Promise.race([primaryP, fallbackP])
// If the first response failed, wait for the second
if (!resp.ok) {
const results = await Promise.all([primaryP, fallbackP])
// Find the first good result, if it exists
;[resp, timing, body] = results.find(([r]) => r.ok) || results[0]
}
// Cancel the other request
// We need seperate AbortControllers & abort calls since abort also cancels the *response*
// normalize the URLs so we can actually compare
if (new URL(resp.url).href === new URL(primaryURL).href) {
fallbackA.abort()
} else {
primaryA.abort()
}
}
// Async report to MD@H server how it went, if applicable
if (primaryURL && /mangadex\.network/.test(primaryURL)) {
const success = (new URL(resp.url).href === new URL(primaryURL).href) && resp.ok
fetch('https://api.mangadex.network/report', {
method: 'post',
mode: 'cors',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: primaryURL,
success: success,
bytes: success ? body.size : 0,
duration: success ? timing : 0,
cached: success && resp.headers.get('X-Cache') === 'HIT',
}),
keepalive: true, // Keep sending the report even if the user leaves the page as we send it
})
.then(res => {
if (!res.ok) throw res
})
.catch(e => null)
}
return URL.createObjectURL(body)
}
function sleep(ms, signal) {
return new Promise((resolve) => {
if (signal.aborted) return resolve()
const t = setTimeout(resolve, ms)
signal.addEventListener('abort', () => {
clearTimeout(t)
resolve()
})
})
}
async function fetchWithTiming(url, signal) {
let resp, body
const networkImage = /mangadex\.network/.test(url)
const start = 'performance' in self ? performance.now() : +new Date()
const hashM = /\/(?:data)\/.*-([0-9a-f]{64})\.[a-z]{3,4}$/.exec(url)
resp = await fetch(url, {
mode: 'cors',
cache: networkImage ? 'no-store' : 'default',
referrer: 'no-referrer',
redirect: 'error', // Cross-origin redirects strip required headers, so explicitly error instead
integrity: hashM ? `sha256-${hex2b64(hashM[1])}` : undefined, // if the image has a hash in it, check that it matches what we got
signal, // Cancel the fetch if we already got a response
})
body = await resp.blob()
const end = 'performance' in self ? performance.now() : +new Date()
return [resp, end - start, body]
}
function hex2b64(s) {
let b = ''
const n = Math.ceil(s.length / 2)
for (let i = 0, o = 0; i < n; i++, o += 2) {
b += String.fromCharCode(parseInt(s.substr(o, 2), 16))
}
return btoa(b)
}