mirror of
https://github.com/holo-gfx/mangadex.git
synced 2024-11-25 02:38:22 -05:00
214 lines
7.2 KiB
JavaScript
214 lines
7.2 KiB
JavaScript
|
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)
|
||
|
}
|