From 4b070bb573f1f0c6737340ffcb8923b30c3e64b4 Mon Sep 17 00:00:00 2001 From: ThaUnknown Date: Thu, 25 Mar 2021 11:57:50 +0100 Subject: [PATCH] first pass at OOP player --- app/css/player.css | 1 + app/index.html | 36 +- app/js/animeHandler.js | 9 +- app/js/player.js | 1061 +++++++++++++++++++++ app/js/playerHandler.js | 698 -------------- app/js/settingsHandler.js | 2 +- app/js/subtitleOctopus.js | 123 --- app/js/subtitles-octopus.js | 1769 +++++++++++++++++------------------ app/js/torrentHandler.js | 234 ----- app/test.html | 1 + support.html | 7 +- 11 files changed, 1973 insertions(+), 1968 deletions(-) create mode 100644 app/js/player.js delete mode 100644 app/js/playerHandler.js delete mode 100644 app/js/subtitleOctopus.js delete mode 100644 app/js/torrentHandler.js diff --git a/app/css/player.css b/app/css/player.css index c4774fe..79c8668 100644 --- a/app/css/player.css +++ b/app/css/player.css @@ -219,6 +219,7 @@ video[src=""], } #dl[disabled] { + display:block !important; cursor: not-allowed; color: rgba(255, 255, 255, .4); } diff --git a/app/index.html b/app/index.html index 7f9a910..ebb21bb 100644 --- a/app/index.html +++ b/app/index.html @@ -189,8 +189,8 @@ 0 B/s
- + get_app
@@ -200,24 +200,24 @@
- + play_arrow - + skip_next - + playlist_play
- + volume_up - +
00:00
- +
00:00 - + picture_in_picture - + crop_16_9 - + fullscreen
@@ -730,8 +730,8 @@
- - + +
@@ -786,13 +786,11 @@ + + - - - - \ No newline at end of file diff --git a/app/js/animeHandler.js b/app/js/animeHandler.js index c44a011..1db6ebb 100644 --- a/app/js/animeHandler.js +++ b/app/js/animeHandler.js @@ -14,7 +14,7 @@ window.addEventListener('paste', async e => { // WAIT image lookup on paste, or if (torrentRx.exec(text)) { e.preventDefault() search.value = '' - addTorrent(text, {}) + client.addTorrent(text, {}) } else if (imageRx.exec(text)) { e.preventDefault() search.value = '' @@ -642,7 +642,7 @@ async function resolveFileMedia (opts) { // episode is still out of bounds const nextEdge = await alRequest({ method: 'SearchIDSingle', id: tempMedia.id }) await resolveSeason({ media: nextEdge.data.Media, episode: opts.episode, offset: opts.offset + nextEdge.data.Media.episodes, increment: increment }) - } else if (tempMedia?.episodes && epMax - (opts.offset + tempMedia.episodes) < (media.episodes || media.nextAiringEpisode.episode) && epMin - (opts.offset + tempMedia.episodes) > 0) { + } else if (tempMedia?.episodes && epMax - (opts.offset + tempMedia.episodes) <= (media.episodes || media.nextAiringEpisode.episode) && epMin - (opts.offset + tempMedia.episodes) > 0) { // episode is in range, seems good! overwriting media to count up "seasons" if (opts.episode.constructor === Array) { episode = `${elems.episode_number[0] - (opts.offset + tempMedia.episodes)} - ${elems.episode_number[elems.episode_number.length - 1] - (opts.offset + tempMedia.episodes)}` @@ -654,7 +654,7 @@ async function resolveFileMedia (opts) { media = nextEdge.data.Media } } else { - console.log('error in parsing!') + console.log('error in parsing!', opts.media, tempMedia) // something failed, most likely couldnt find an edge or processing failed, force episode number even if its invalid/out of bounds, better than nothing if (opts.episode.constructor === Array) { episode = `${Number(elems.episode_number[0])} - ${Number(elems.episode_number[elems.episode_number.length - 1])}` @@ -690,7 +690,8 @@ async function resolveFileMedia (opts) { return { media: media, episode: episode, parseObject: elems } } -const store = JSON.parse(localStorage.getItem('store')) || {} +let store = JSON.parse(localStorage.getItem('store')) || {} +store = store || {} function getRSSurl () { if (Object.values(torrent4list.options).filter(item => item.value === settings.torrent4)[0]) { diff --git a/app/js/player.js b/app/js/player.js new file mode 100644 index 0000000..b6cc994 --- /dev/null +++ b/app/js/player.js @@ -0,0 +1,1061 @@ +class TorrentPlayer extends WebTorrent { + constructor (options = {}) { + super(options.WebTorrentOpts) + + this.worker = navigator.serviceWorker.register('sw.js', { scope: '/app/' }).then(() => { + if (searchParams.get('file')) addTorrent(searchParams.get('file'), {}) // add a torrent if its in the link params + }).catch(e => { + if (String(e) === 'InvalidStateError: Failed to register a ServiceWorker: The document is in an invalid state.') { + location.reload() // weird workaround for a weird bug + } else { + throw e + } + }) + window.addEventListener('beforeunload', () => { + this.cleanupVideo() + this.cleanupTorrents() + }) + // kind of a fetch event from service worker but for the main thread. + navigator.serviceWorker.addEventListener('message', evt => { + const request = new Request(evt.data.url, { + headers: evt.data.headers, + method: evt.data.method + }) + + const [port] = evt.ports + const respondWith = msg => port.postMessage(msg) + const pathname = request.url.split(evt.data.scope + 'webtorrent/')[1] + let [infoHash, ...filePath] = pathname.split('/') + filePath = decodeURI(filePath.join('/')) + + if (!infoHash || !filePath) return + + const torrent = this.get(infoHash) + const file = torrent.files.find(file => file.path === filePath) + + const [response, stream] = this.serveFile(file, request) + const asyncIterator = stream && stream[Symbol.asyncIterator]() + respondWith(response) + async function pull (msg) { + if (msg.data) { + const chunk = (await asyncIterator.next()).value + respondWith(chunk) + if (!chunk) port.onmessage = null + } else { + console.log('Closing stream') + stream.destroy() + port.onmessage = null + } + } + + port.onmessage = pull + }) + + this.video = options.video + this.controls = options.controls // object of controls + // playPause, playNext, openPlaylist, toggleMute, setVolume, setProgress, showCaptions, showAudio, toggleTheatre, toggleFullscreen, togglePopout, downloadFile + + this.controls.setVolume.addEventListener('input', (e) => this.setVolume(e.target.value)) + this.setVolume() + ptoggle.addEventListener('click', () => this.playPause()) // non-tplayer specific // TODO: fix this + this.oldVolume = undefined + + this.controls.setProgress.addEventListener('input', (e) => this.setProgress(e.target.value)) + this.controls.setProgress.addEventListener('mouseup', (e) => this.dragBarEnd(e.target.value)) + this.controls.setProgress.addEventListener('thouchend', (e) => this.dragBarEnd(e.target.value)) + this.controls.setProgress.addEventListener('click', (e) => this.dragBarEnd(e.target.value)) + this.controls.setProgress.addEventListener('mousedown', (e) => this.dragBarStart(e.target.value)) + this.video.addEventListener('timeupdate', (e) => { + if (this.immerseTimeout && document.location.hash === '#player') this.setProgress(e.target.currentTime / e.target.duration * 100) + }) + + this.player = options.player + this.playerWrapper = options.playerWrapper + this.player.addEventListener('fullscreenchange', () => this.updateFullscreen()) + ptoggle.addEventListener('dblclick', () => this.toggleFullscreen()) // non-tplayer specific // TODO: fix this + + this.subtitleData = { + fonts: [], + headers: [], + tracks: [], + current: undefined, + renderer: undefined, + stream: undefined, + parser: undefined, + parsed: undefined, + timeout: undefined, + defaultHeader: `[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,${options.defaultSSAStyles} +[Events] + +` + } + + this.video.addEventListener('loadedmetadata', () => this.video.audioTracks?.length > 1 ? this.controls.showAudio.removeAttribute('disabled') : this.controls.showAudio.setAttribute('disabled', '')) + + this.completed = undefined + this.onWatched = options.onWatched + if (this.onWatched) this.video.addEventListener('timeupdate', () => this.checkCompletion()) + + this.onPlaylist = options.onPlaylist + + this.onNext = options.onNext + this.nextTimeout = undefined + if (this.onNext && options.autoNext) this.video.addEventListener('ended', () => this.onNext()) + + this.currentFile = undefined + this.videoFile = undefined + + this.generateThumbnails = options.generateThumbnails + const thumbCanvas = document.createElement('canvas') + thumbCanvas.width = options.thumbnailWidth || 150 + this.thumbnailData = { + thumbnails: [], + canvas: thumbCanvas, + context: thumbCanvas.getContext('2d'), + interval: undefined, + video: undefined + } + this.video.addEventListener('loadedmetadata', () => this.initThumbnail()) + this.video.addEventListener('timeupdate', () => this.createThumbnail(this.video)) + + if (options.visibilityLossPause) { + document.addEventListener('visibilitychange', () => { + if (!this.video.ended) document.visibilityState === 'hidden' ? this.video.pause() : this.playPause() + }) + } + + this.onDownloadDone = options.onDownloadDone + this.onDone = undefined + + this.immerseTimeout = undefined + this.immerseTime = options.immerseTime || 5 + this.player.addEventListener('mousemove', () => requestAnimationFrame(() => this.resetImmerse())) + this.player.addEventListener('keypress', () => requestAnimationFrame(() => this.resetImmerse())) + + this.bufferTimeout = undefined + this.video.addEventListener('playing', () => this.hideBuffering()) + this.video.addEventListener('canplay', () => this.hideBuffering()) + this.video.addEventListener('timeupdate', () => this.hideBuffering()) + this.video.addEventListener('waiting', () => this.showBuffering()) + + if ('pictureInPictureEnabled' in document) { + this.burnIn = options.burnIn + this.controls.togglePopout.removeAttribute('disabled') + if (this.burnIn) this.video.addEventListener('enterpictureinpicture', () => { if (this.subtitleData.renderer) this.togglePopout() }) + } else { + this.video.setAttribute('disablePictureInPicture', '') + this.controls.togglePopout.setAttribute('disabled', '') + } + + this.seekTime = options.seekTime || 5 + if ('mediaSession' in navigator) { + navigator.mediaSession.setActionHandler('play', () => this.playPause()) + navigator.mediaSession.setActionHandler('pause', () => this.playPause()) + navigator.mediaSession.setActionHandler('seekbackward', () => this.seek(seekTime)) + navigator.mediaSession.setActionHandler('seekforward', () => this.seek(seekTime)) + navigator.mediaSession.setActionHandler('nexttrack', () => this.playNext()) + } + if ('setPositionState' in navigator.mediaSession) video.addEventListener('timeupdate', () => this.updatePositionState()) + + this.videoExtensions = ['.3g2', '.3gp', '.asf', '.avi', '.dv', '.flv', '.gxf', '.m2ts', '.m4a', '.m4b', '.m4p', '.m4r', '.m4v', '.mkv', '.mov', '.mp4', '.mpd', '.mpeg', '.mpg', '.mxf', '.nut', '.ogm', '.ogv', '.swf', '.ts', '.vob', '.webm', '.wmv', '.wtv'] + this.videoFiles = undefined + + this.currentTorrent = undefined + this.offlineTorrents = JSON.parse(localStorage.getItem('offlineTorrents')) || {} + // adds all offline store torrents to the client + Object.values(this.offlineTorrents).forEach(torrentID => this.offlineDownload(new Blob([new Uint8Array(torrentID)]), true)) + + this.streamedDownload = options.streamedDownload + + this.fps = 23.976 + if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) { + this.video.addEventListener('loadedmetadata', () => { + this.fps = new Promise((resolve, reject) => { + this.video.onplay = () => { + this.video.onplay = undefined + setTimeout(() => this.video.requestVideoFrameCallback((now, metaData) => { + let duration = 0 + for (let index = this.video.played.length; index--;) { + duration += this.video.played.end(index) - this.video.played.start(index) + } + const rawFPS = metaData.presentedFrames / duration + console.log(rawFPS, metaData) + if (rawFPS < 28) { + resolve(23.976) + } else if (rawFPS <= 35) { + resolve(29.97) + } else if (rawFPS <= 70) { + resolve(59.94) + } else { + resolve(23.976) // smth went VERY wrong + } + }), 2000) + } + this.playVideo() + }) + }) + } + + for (const [key, value] of Object.entries(this.controls)) { + value.addEventListener('click', (e) => { + this[key](e.target.value) + }) + } + document.onkeydown = a => { + if (a.key === 'F5') { + a.preventDefault() + } + if (document.location.hash === '#player') { + switch (a.key) { + case ' ': + this.playPause() + break + case 'n': + this.playNext() + break + case 'm': + this.toggleMute() + break + case 'p': + this.togglePopout() + break + case 't': + this.toggleTheatre() + break + case 'c': + this.captions() + break + case 'f': + this.toggleFullscreen() + break + case 's': + this.seek(85) + break + case 'ArrowLeft': + this.seek(-this.seekTime) + break + case 'ArrowRight': + this.seek(this.seekTime) + break + case 'ArrowUp': + this.setVolume(Number(this.controls.setVolume.value) + 5) + break + case 'ArrowDown': + this.setVolume(Number(this.controls.setVolume.value) - 5) + break + case 'Escape': + document.location.hash = '#home' + break + } + } + } + } + + serveFile (file, req) { + const res = { + status: 200, + headers: { + 'Content-Type': file._getMimeType(), + // Support range-requests + 'Accept-Ranges': 'bytes' + } + } + + // `rangeParser` returns an array of ranges, or an error code (number) if + // there was an error parsing the range. + let range = rangeParser(file.length, req.headers.get('range') || '') + + if (Array.isArray(range)) { + res.status = 206 // indicates that range-request was understood + + // no support for multi-range request, just use the first range + range = range[0] + + res.headers['Content-Range'] = `bytes ${range.start}-${range.end}/${file.length}` + res.headers['Content-Length'] = `${range.end - range.start + 1}` + } else { + range = null + res.headers['Content-Length'] = file.length + } + + res.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0' + res.headers.Expires = '0' + res.body = req.method === 'HEAD' ? '' : 'stream' + // parser is really a passthrough mkv stream now + const stream = file.createReadStream(range) + if ((file.name.endsWith('.mkv') || file.name.endsWith('.webm')) && !this.subtitleData.parsed) { + this.subtitleData.stream = new MatroskaSubtitles.SubtitleStream(this.subtitleData.stream) + this.handleSubtitleParser(this.subtitleData.stream) + stream.pipe(this.subtitleData.stream) + } + + return [res, req.method === 'GET' && (this.subtitleData.stream || stream)] + } + + async buildVideo (torrent, opts) { // sets video source and creates a bunch of other media stuff + // play wanted episode from opts, or the 1st episode, or 1st file [batches: plays wanted episode, single: plays the only episode, manually added: plays first or only file] + if (opts.file) { + this.currentFile = opts.file + } else if (this.videoFiles.length > 1) { + // TODO play selected media too! + this.currentFile = this.videoFiles.filter(async file => await anitomyscript(file.name).then(object => Number(object.episode_number) === Number(opts.episode || 1)))[0] || this.videoFiles[0] + } else { + this.currentFile = this.videoFiles[0] + } + this.currentTorrent = torrent + this.video.src = `/app/webtorrent/${torrent.infoHash}/${encodeURI(this.currentFile.path)}` + this.video.load() + + if (this.videoFiles.length > 1) bpl.removeAttribute('disabled') + + if (this.currentFile.done) { + this.postDownload() + } else { + this.onDone = this.currentFile.on('done', () => { + this.postDownload() + }) + } + + if (opts.media && this.videoFiles.length === 1) { + // if this is a single file, then the media is most likely accurate, just update it! + this.nowPlaying = [await alRequest({ id: opts.media?.id, method: 'SearchIDSingle' }).then(res => res.data.Media), opts.episode || 1] + // update store with entry, but dont really do anything with it + resolveFileMedia({ fileName: this.currentFile.name, method: 'SearchName' }) + } else { + // if this is a batch or single unresolved file, then resolve the single selected file, batches can include specials + const mediaInformation = await resolveFileMedia({ fileName: this.currentFile.name, method: 'SearchName' }) + this.nowPlaying = [mediaInformation.media, mediaInformation.episode || 1] + } + let mediaMetadata + // only set mediasession and other shit if the playerdata is parsed correctly + if (this.nowPlaying[0]) { // TODO: fix!!!!! + navNowPlaying.classList.remove('d-none') + mediaMetadata = new MediaMetadata({ + title: this.nowPlaying[0].title.userPreferred, + artist: `Episode ${Number(this.nowPlaying[1])}`, + album: 'Miru', + artwork: [{ + src: this.nowPlaying[0].coverImage.medium, + sizes: '256x256', + type: 'image/jpg' + }] + }) + if (parseInt(this.nowPlaying[1]) >= this.nowPlaying[0].episodes) bnext.setAttribute('disabled', '') + let streamingEpisode + if (this.nowPlaying[0].streamingEpisodes.length >= Number(this.nowPlaying[1])) { + streamingEpisode = this.nowPlaying[0].streamingEpisodes.filter(episode => episodeRx.exec(episode.title) && Number(episodeRx.exec(episode.title)[1]) === Number(this.nowPlaying[1]))[0] + } + // TODO: this should also use absolute episode numbers instead of relative but AL will change this anyways.... + if (streamingEpisode) { + video.poster = streamingEpisode.thumbnail + document.title = `${this.nowPlaying[0].title.userPreferred} - EP ${Number(this.nowPlaying[1])} - ${episodeRx.exec(streamingEpisode.title)[2]} - Miru` + mediaMetadata.artist = `Episode ${Number(this.nowPlaying[1])} - ${episodeRx.exec(streamingEpisode.title)[2]}` + mediaMetadata.artwork = [{ + src: streamingEpisode.thumbnail, + sizes: '256x256', + type: 'image/jpg' + }] + nowPlayingDisplay.innerHTML = `EP ${Number(this.nowPlaying[1])} - ${episodeRx.exec(streamingEpisode.title)[2]}` + } else { + document.title = `${this.nowPlaying[0].title.userPreferred} - EP ${Number(this.nowPlaying[1])} - Miru` + nowPlayingDisplay.innerHTML = `EP ${Number(this.nowPlaying[1])}` + } + } + if ('mediaSession' in navigator && mediaMetadata) navigator.mediaSession.metadata = mediaMetadata + } + + cleanupVideo () { // cleans up objects, attemps to clear as much video caching as possible + if (this.subtitleData.renderer) this.subtitleData.renderer.dispose() + if (this.subtitleData.fonts) this.subtitleData.fonts.forEach(file => URL.revokeObjectURL(file)) // ideally this should clean up after its been downloaded by the sw renderer, but oh well + this.controls.downloadFile.setAttribute('disabled', '') + this.video.poster = '' + // some attemt at cache clearing + this.video.pause() + this.video.src = '' + this.video.load() + this.onDone = undefined + document.title = 'Miru' + this.setProgress(0) + // if (typeof client !== 'undefined' && client.torrents[0] && client.torrents[0].files.length > 1) { + // client.torrents[0].files.forEach(file => file.deselect()); + // client.torrents[0].deselect(0, client.torrents[0].pieces.length - 1, false); + // console.log(videoFiles.filter(file => `${scope}webtorrent/${client.torrents[0].infoHash}/${encodeURI(file.path)}` == video.src)) + // look for file and delete its store + // } + this.subtitleData = { + fonts: [], + headers: [], + tracks: [], + current: undefined, + renderer: undefined, + stream: undefined, + parser: undefined, + parsed: undefined, + timeout: undefined, + defaultHeader: this.subtitleData.defaultHeader + } + + this.thumbnailData = { + thumbnails: [], + canvas: this.thumbnailData.canvas, + context: this.thumbnailData.context, + interval: undefined, + video: undefined + } + nowPlayingDisplay.innerHTML = '' // TODO: fix + this.controls.showCaptions.setAttribute('disabled', '') + this.controls.openPlaylist.setAttribute('disabled', '') + this.controls.playNext.removeAttribute('disabled') + navNowPlaying.classList.add('d-none') // TODO: fix + if ('mediaSession' in navigator) navigator.mediaSession.metadata = undefined + this.fps = 23.976 + } + + async playVideo () { + try { + await this.video.play() + this.controls.playPause.innerHTML = 'pause' + } catch (err) { + this.controls.playPause.innerHTML = 'play_arrow' + } + } + + playPause () { + if (this.video.paused) { + this.playVideo() + } else { + this.controls.playPause.innerHTML = 'play_arrow' + this.video.pause() + } + } + + setVolume (volume) { + const level = volume === undefined ? Number(this.controls.setVolume.value) : volume + this.controls.setVolume.value = level + this.controls.setVolume.style.setProperty('--volume-level', level + '%') + this.controls.toggleMute.innerHTML = level === 0 ? 'volume_off' : 'volume_up' + this.video.volume = level / 100 + } + + toggleMute () { + if (this.video.volume === 0) { + this.setVolume(this.oldVolume) + } else { + this.oldVolume = this.video.volume * 100 + this.setVolume(0) + } + } + + toggleTheatre () { + this.playerWrapper.classList.toggle('nav-hidden') + } + + toggleFullscreen () { + document.fullscreenElement ? document.exitFullscreen() : this.player.requestFullscreen() + } + + updateFullscreen () { + document.fullscreenElement ? this.controls.toggleFullscreen.innerHTML = 'fullscreen_exit' : this.controls.toggleFullscreen.innerHTML = 'fullscreen' + } + + openPlaylist () { + if (this.onPlaylist) this.onPlaylist() + } + + playNext () { + clearTimeout(this.nextTimeout) + this.nextTimeout = setTimeout(() => { + if (this.videoFiles?.length > 1 && this.videoFiles.indexOf(this.currentFile) < this.videoFiles.length) { + const fileIndex = this.videoFiles.indexOf(this.currentFile) + 1 + const nowPlaying = [this.nowPlaying[0], parseInt(this.nowPlaying[1]) + 1] // TODO: fix + cleanupVideo() + buildVideo(this.videoFiles[fileIndex], nowPlaying) // TODO: fix + } else { + if (this.onNext) this.onNext() + } + }, 200) + } + + async togglePopout () { + if (this.video.readyState) { + if (!(this.burnIn && this.subtitleData.renderer)) { + this.video !== document.pictureInPictureElement ? await this.video.requestPictureInPicture() : await document.exitPictureInPicture() + } else { + if (document.pictureInPictureElement && !document.pictureInPictureElement.id) { // only exit if pip is the custom one, else overwrite existing pip with custom + await document.exitPictureInPicture() + } else { + const canvas = document.createElement('canvas') + const canvasVideo = document.createElement('video') + const context = canvas.getContext('2d', { alpha: false }) + let running = true + canvas.width = this.video.videoWidth + canvas.height = this.video.videoHeight + + const renderFrame = () => { + if (running === true) { + context.drawImage(this.video, 0, 0) + context.drawImage(subtitleCanvas, 0, 0, canvas.width, canvas.height) // TODO: reference SO canvas + window.requestAnimationFrame(renderFrame) + } + } + canvasVideo.srcObject = canvas.captureStream() + canvasVideo.onloadedmetadata = () => { + canvasVideo.play() + canvasVideo.requestPictureInPicture().then( + this.player.classList.add('pip') + ).catch(e => { + console.warn('Failed To Burn In Subtitles ' + e) + running = false + canvasVideo.remove() + canvas.remove() + this.player.classList.remove('pip') + }) + } + canvasVideo.onleavepictureinpicture = () => { + running = false + canvasVideo.remove() + canvas.remove() + this.player.classList.remove('pip') + } + window.requestAnimationFrame(renderFrame) + } + } + } + } + + toTS (sec, full) { + if (isNaN(sec) || sec < 0) { + return full ? '0:00:00.00' : '00:00' + } + const hours = Math.floor(sec / 3600) + let minutes = Math.floor(sec / 60) - (hours * 60) + let seconds = full ? (sec % 60).toFixed(2) : Math.floor(sec % 60) + if (minutes < 10) minutes = '0' + minutes + if (seconds < 10) seconds = '0' + seconds + return (hours > 0 || full) ? hours + ':' + minutes + ':' + seconds : minutes + ':' + seconds + } + + prettyBytes (num) { + const neg = num < 0; const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + if (neg) num = -num + if (num < 1) return (neg ? '-' : '') + num + ' B' + const exponent = Math.min(Math.floor(Math.log(num) / Math.log(1000)), units.length - 1) + num = Number((num / Math.pow(1000, exponent)).toFixed(2)) + const unit = units[exponent] + return (neg ? '-' : '') + num + ' ' + unit + } + + seek (time) { + if (time === 85 && this.video.currentTime < 10) { + this.video.currentTime = 90 + } else if (time === 85 && (this.video.duration - this.video.currentTime) < 90) { + this.video.currentTime = this.video.duration + } else { + this.video.currentTime += time + } + this.setProgress(this.video.currentTime / this.video.duration * 100) + } + + immersePlayer () { + this.player.classList.add('immersed') + this.immerseTimeout = undefined + } + + resetImmerse () { + if (!this.immerseTimeout) { + clearTimeout(this.immerseTimeout) + this.player.classList.remove('immersed') + this.immerseTimeout = setTimeout(() => this.immersePlayer(), this.immerseTime * 1000) + } + } + + hideBuffering () { + if (this.bufferTimeout) { + clearTimeout(this.bufferTimeout) + this.bufferTimeout = undefined + buffering.classList.add('hidden') // non-tplayer specific // TODO: fix this + } + } + + showBuffering () { + this.bufferTimeout = setTimeout(() => { + buffering.classList.remove('hidden') // non-tplayer specific // TODO: fix this + this.resetImmerse() + }, 150) + } + + checkCompletion () { + if (!this.completed && video.duration - 180 < video.currentTime) { + this.onWatched() + } else { + this.completed = true + } + } + + updatePositionState () { + if (this.video.duration) { + navigator.mediaSession.setPositionState({ + duration: this.video.duration || 0, + playbackRate: this.video.playbackRate || 0, + position: this.video.currentTime || 0 + }) + } + } + + initThumbnail () { + const height = this.thumbnailData.canvas.width / (this.video.videoWidth / this.video.videoHeight) + this.thumbnailData.interval = this.video.duration / 300 < 5 ? 5 : this.video.duration / 300 + this.thumbnailData.canvas.height = height + thumb.style.setProperty('--height', height + 'px') // TODO: fix + } + + createThumbnail (video) { + if (video?.readyState >= 2) { + const index = Math.floor(video.currentTime / this.thumbnailData.interval) + if (!this.thumbnailData.thumbnails[index]) { + this.thumbnailData.context.fillRect(0, 0, 150, this.thumbnailData.canvas.height) + this.thumbnailData.context.drawImage(video, 0, 0, 150, this.thumbnailData.canvas.height) + this.thumbnailData.thumbnails[index] = this.thumbnailData.canvas.toDataURL('image/jpeg') + } + } + } + + finishThumbnails (src) { + const t0 = performance.now() + const video = document.createElement('video') + let index = 0 + video.preload = 'none' + video.volume = 0 + video.playbackRate = 0 + video.addEventListener('loadeddata', () => loadTime()) + video.addEventListener('canplay', () => { + this.createThumbnail(this.thumbnailData.video) + loadTime() + }) + this.thumbnailData.video = video + const loadTime = () => { + while (this.thumbnailData.thumbnails[index] && index <= Math.floor(this.thumbnailData.video.duration / this.thumbnailData.interval)) { // only create thumbnails that are missing + index++ + } + if (this.thumbnailData.video?.currentTime !== this.thumbnailData.video?.duration) { + this.thumbnailData.video.currentTime = index * this.thumbnailData.interval + } else { + this.thumbnailData.video?.removeAttribute('src') + this.thumbnailData.video?.load() + this.thumbnailData.video?.remove() + delete this.thumbnailData.video + console.log('Thumbnail creating finished', index, this.toTS((performance.now() - t0) / 1000)) + } + index++ + } + this.thumbnailData.video.src = src + this.thumbnailData.video.play() + console.log('Thumbnail creating started') + } + + dragBarEnd (progressPercent) { + this.video.currentTime = this.video.duration * progressPercent / 100 || 0 + this.playVideo() + } + + async dragBarStart (progressPercent) { + await this.video.pause() + this.setProgress(progressPercent) + } + + setProgress (progressPercent) { + progressPercent = progressPercent || 0 + const currentTime = this.video.duration * progressPercent / 100 || 0 + this.controls.setProgress.style.setProperty('--progress', progressPercent + '%') + thumb.src = this.thumbnailData.thumbnails[Math.floor(currentTime / this.thumbnailData.interval)] || ' ' // TODO: fix + thumb.style.setProperty('--progress', progressPercent + '%') // TODO: fix + elapsed.innerHTML = this.toTS(currentTime) // TODO: fix + remaining.innerHTML = this.toTS(this.video.duration - currentTime) // TODO: fix + this.controls.setProgress.value = progressPercent + this.controls.setProgress.setAttribute('data-ts', this.toTS(currentTime)) + } + + updateDisplay () { + this.player.style.setProperty('--download', this.selectedFile.progress * 100 + '%') + peers.innerHTML = this.selectedTorrent.numPeers + downSpeed.innerHTML = this.prettyBytes(this.selectedTorrent.downloadSpeed) + '/s' + upSpeed.innerHTML = this.prettyBytes(this.selectedTorrent.uploadSpeed) + '/s' + } + + showAudio () { + const frag = document.createDocumentFragment() + for (const track of this.video.audioTracks) { + const template = document.createElement('a') + template.classList.add('dropdown-item', 'pointer', 'text-capitalize') + template.innerHTML = (track.language || (!Object.values(this.video.audioTracks).some(track => track.language === 'eng' || track.language === 'en') ? 'eng' : track.label)) + (track.label ? ' - ' + track.label : '') + track.enabled === true ? template.classList.add('text-white') : template.classList.add('text-muted') + template.onclick = () => this.selectAudio(track.id) + frag.appendChild(template) + } + + audioTracksMenu.innerHTML = '' + audioTracksMenu.appendChild(frag) + } + + selectAudio (id) { + for (const track of this.video.audioTracks) { + track.id === id ? track.enabled = true : track.enabled = false + } + this.seek(-0.5) // stupid fix because video freezes up when chaging tracks + this.showAudio() + } + + // subtitles, generates content every single time its opened because fuck knows when the parser will find new shit + // this needs to go.... really badly + showCaptions () { + const frag = document.createDocumentFragment() + const off = document.createElement('a') + off.classList.add('dropdown-item', 'pointer') + this.subtitleData.current ? off.classList.add('text-muted') : off.classList.add('text-white') + off.innerHTML = 'OFF' + off.onclick = () => this.selectCaptions() + frag.appendChild(off) + for (const track of this.subtitleData.headers) { + if (track) { + const template = document.createElement('a') + template.classList.add('dropdown-item', 'pointer', 'text-capitalize') + template.innerHTML = (track.language || (!Object.values(this.subtitleData.headers).some(header => header.language === 'eng' || header.language === 'en') ? 'eng' : header.type)) + (track.name ? ' - ' + track.name : '') + if (this.subtitleData.current === track.number) { + template.classList.add('text-white') + } else { + template.classList.add('text-muted') + } + template.onclick = () => this.selectCaptions(track.number) + frag.appendChild(template) + } + } + const timeOffset = document.createElement('div') + timeOffset.classList.add('btn-group', 'w-full', 'pt-5') + timeOffset.setAttribute('role', 'group') + timeOffset.innerHTML = ` +` + frag.appendChild(timeOffset) + subMenu.innerHTML = '' + subMenu.appendChild(frag) + } + + selectCaptions (trackNumber) { + this.subtitleData.current = trackNumber + this.showCaptions() + if (!this.subtitleData.timeout) { + this.subtitleData.timeout = setTimeout(() => { + this.subtitleData.timeout = undefined + if (this.subtitleData.renderer) { + this.subtitleData.renderer.setTrack(trackNumber ? this.subtitleData.headers[trackNumber].header.slice(0, -1) + Array.from(this.subtitleData.tracks[trackNumber]).join('\n') : this.subtitleData.headers[3].header.slice(0, -1)) + } + }, 1000) + } + } + + constructSub (subtitle, isNotAss) { + if (isNotAss) { // converts VTT or other to SSA + const matches = subtitle.text.match(/<[^>]+>/g) // create array of all tags + if (matches) { + matches.forEach(match => { + if (/<\//.test(match)) { // check if its a closing tag + subtitle.text = subtitle.text.replace(match, match.replace('', '0}')) + } else { + subtitle.text = subtitle.text.replace(match, match.replace('<', '{\\').replace('>', '1}')) + } + }) + } + // replace all html special tags with normal ones + subtitle.text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /g, '\\h') + } + return 'Dialogue: ' + + (subtitle.layer || 0) + ',' + + this.toTS(subtitle.time / 1000, true) + ',' + + this.toTS((subtitle.time + subtitle.duration) / 1000, true) + ',' + + (subtitle.style || 'Default') + ',' + + (subtitle.name || '') + ',' + + (subtitle.marginL || '0') + ',' + + (subtitle.marginR || '0') + ',' + + (subtitle.marginV || '0') + ',' + + (subtitle.effect || '') + ',' + + subtitle.text + } + + async parseSubtitles (file) { // parse subtitles fully after a download is finished + return new Promise((resolve, reject) => { + if (file.name.endsWith('.mkv') || file.name.endsWith('.webm')) { + let parser = new MatroskaSubtitles.SubtitleParser() + this.handleSubtitleParser(parser, true) + parser.on('finish', () => { + console.log('Sub parsing finished') + this.subtitleData.parsed = true + this.subtitleData.stream = undefined + this.selectCaptions(this.subtitleData.current) + parser = undefined + this.controls.showCaptions.removeAttribute('disabled') + if (!this.video.paused) { + this.video.pause() + this.playVideo() + } + resolve() + }) + console.log('Sub parsing started') + this.subtitleData.parser = file.createReadStream().pipe(parser) + // when this gets overwritten the parser stays so it might "leak" some RAM??? + } else { + resolve() + } + }) + } + + handleSubtitleParser (parser, skipFile) { + parser.once('tracks', tracks => { + this.controls.showCaptions.removeAttribute('disabled') + tracks.forEach(track => { + // overwrite webvtt or other header with custom one + if (track.type !== 'ass') track.header = this.subtitleData.defaultHeader + if (!this.subtitleData.current) this.subtitleData.current = track.number + if (!this.subtitleData.tracks[track.number]) this.subtitleData.tracks[track.number] = new Set() + this.subtitleData.headers[track.number] = track + }) + }) + parser.on('subtitle', (subtitle, trackNumber) => { + if (!this.subtitleData.parsed) { + if (!this.subtitleData.renderer) this.initSubtitleRenderer() + this.subtitleData.tracks[trackNumber].add(this.constructSub(subtitle, this.subtitleData.headers[trackNumber].type === 'webvtt')) + if (this.subtitleData.current === trackNumber) this.selectCaptions(trackNumber) + } + }) + if (!skipFile) { + parser.on('file', file => { + if (file.mimetype === 'application/x-truetype-font' || file.mimetype === 'application/font-woff') { + this.subtitleData.fonts.push(window.URL.createObjectURL(new Blob([file.data], { type: file.mimetype }))) + } + }) + } + } + + async initSubtitleRenderer () { + if (!this.subtitleData.renderer) { + const options = { + video: this.video, + targetFps: await this.fps, + subContent: this.subtitleData.headers[this.subtitleData.current].header.slice(0, -1), + renderMode: 'offscreenCanvas', + fonts: this.subtitleData.fonts.length ? this.subtitleData.fonts : ['https://fonts.gstatic.com/s/roboto/v20/KFOlCnqEu92Fr1MmEU9fBBc4.woff2'], + workerUrl: 'js/subtitles-octopus-worker.js', + timeOffset: 0, + onReady: () => { // weird hack for laggy subtitles, this is some issue in SO + if (!this.video.paused) { + this.video.pause() + this.playVideo() + } + } + } + if (!this.subtitleData.renderer) { + console.log() + this.subtitleData.renderer = new SubtitlesOctopus(options) + this.selectCaptions(this.subtitleData.current) + } + } + } + + async downloadFile () { + if (this.currentFile?.done && !this.currentTorrent.store.store._store) { + this.currentFile.getBlobURL((err, url) => { + if (err) throw err + const a = document.createElement('a') + a.download = this.currentFile.name + a.href = url + document.body.appendChild(a) + a.click(e) + a.remove() + window.URL.revokeObjectURL(url) + }) + } + } + + async postDownload () { + this.onDownloadDone(this.currentFile.name) + await this.parseSubtitles(this.currentFile) + if (this.generateThumbnails) { + this.finishThumbnails(this.video.src) + } + if (!this.currentTorrent.store.store._store) { // only allow download from RAM + this.controls.downloadFile.removeAttribute('disabled') + } + } + + // this should be cleaned up + addTorrent (torrentID, opts) { + halfmoon.hideModal('tsearch') + document.location.hash = '#player' + this.cleanupVideo() + this.cleanupTorrents() + if (torrentID instanceof Object) { + this.playTorrent(torrentID, opts) + } else if (client.get(torrentID)) { + this.playTorrent(client.get(torrentID), opts) + } else { + this.add(torrentID, settings.torrent5 ? { store: IdbChunkStore } : {}, torrent => { + this.playTorrent(torrent, opts) + if (this.streamedDownload) torrent.deselect(0, torrent.pieces.length - 1, false) + }) + } + } + + async playTorrent (torrent, opts) { + torrent.on('noPeers', () => { + if (torrent.progress !== 1) { + halfmoon.initStickyAlert({ + content: `Couldn't find peers for ${torrent.infoHash}! Try a torrent with more seeders.`, + title: 'Search Failed', + alertType: 'alert-danger', + fillType: '' + }) + } + }) + await this.worker + this.videoFiles = torrent.files.filter(file => this.videoExtensions.some(ext => file.name.endsWith(ext))) + if (this.videoFiles.length > 1) { + (async () => { + torrent.files.forEach(file => file.deselect()) + const frag = document.createDocumentFragment() + for (const file of this.videoFiles) { + const mediaInformation = await resolveFileMedia({ fileName: file.name, method: 'SearchName' }) + const template = cardCreator(mediaInformation) + template.onclick = () => { + this.cleanupVideo() + this.buildVideo(torrent, { media: mediaInformation.media, episode: mediaInformation.parseObject.episode, file: file }) + } + frag.appendChild(template) + } + document.querySelector('.playlist').appendChild(frag) + })() + } + if (this.videoFiles) { + this.buildVideo(torrent, opts) + } else { + halfmoon.initStickyAlert({ + content: `Couldn't find video file for ${torrent.infoHash}!`, + title: 'Search Failed', + alertType: 'alert-danger', + fillType: '' + }) + this.cleanupTorrents() + } + } + + // cleanup torrent and store + cleanupTorrents () { + // creates an array of all non-offline store torrents and removes them + this.torrents.filter(torrent => !this.offlineTorrents[torrent.infoHash]).forEach(torrent => torrent.destroy({ destroyStore: true })) + document.querySelector('.playlist').innerHTML = '' + } + + // add torrent for offline download + offlineDownload (torrentID, skipVerify) { + const torrent = this.add(torrentID, { + store: IdbChunkStore, + skipVerify: skipVerify + }) + torrent.on('metadata', async () => { + console.log(torrent) + if (!this.offlineTorrents[torrent.infoHash]) { + this.offlineTorrents[torrent.infoHash] = Array.from(torrent.torrentFile) + localStorage.setItem('offlineTorrents', JSON.stringify(this.offlineTorrents)) + } + const mediaInformation = await resolveFileMedia({ fileName: torrent.name, method: 'SearchName' }) + const template = cardCreator(mediaInformation) + template.onclick = () => this.addTorrent(torrent, { media: mediaInformation.media, episode: mediaInformation.episode }) + document.querySelector('.downloads').appendChild(template) + }) + } +} + +const announceList = [ + 'wss://tracker.openwebtorrent.com', + 'wss://tracker.sloppyta.co:443/announce', + 'wss://hub.bugout.link:443/announce' +] +const playerControls = {} +for (const item of document.getElementsByClassName('ctrl')) { + playerControls[item.dataset.name] = item +} +const client = new TorrentPlayer({ + WebTorrentOpts: { + maxConns: settings.torrent6, + downloadLimit: settings.torrent7 * 1048576, + uploadLimit: settings.torrent7 * 1572864, + tracker: { + announce: announceList + } + }, + controls: playerControls, + video: video, + player: player, + playerWrapper: pageWrapper, + burnIn: settings.subtitle3, + seekTime: Number(settings.player3), + immerseTime: Number(settings.player2), + visibilityLossPause: settings.player10, + autoNext: settings.player6, + streamedDownload: settings.torrent6, + generateThumbnails: settings.player5, + defaultSSAStyles: Object.values(subtitle1list.options).filter(item => item.value === settings.subtitle1)[0].innerText, + onDownloadDone: (name) => { + halfmoon.initStickyAlert({ + content: `${name} has finished downloading. Now seeding.`, + title: 'Download Complete', + alertType: 'alert-success', + fillType: '' + }) + }, + onWatched: () => { // TODO: fix + if (settings.other2 && !(!(playerData.nowPlaying[0].episodes || playerData.nowPlaying[0].nextAiringEpisode.episode) && playerData.nowPlaying[0].streamingEpisodes.length && parseInt(playerData.nowPlaying[1] > 12))) { + alEntry() + } else { + halfmoon.initStickyAlert({ + content: `Do You Want To Mark
${playerData.nowPlaying[0].title.userPreferred}
Episode ${playerData.nowPlaying[1]} As Completed?
+ + `, + title: 'Episode Complete', + timeShown: 180000 + }) + } + }, + onPlaylist: () => { + window.location.hash = '#playlist' + }, + onNext: () => { + if (playerData.nowPlaying[0]) { + nyaaSearch(playerData.nowPlaying[0], parseInt(playerData.nowPlaying[1]) + 1) + } else { + halfmoon.initStickyAlert({ + content: 'Couldn\'t find anime name! Try specifying a torrent manually.', + title: 'Search Failed', + alertType: 'alert-danger', + fillType: '' + }) + } + } +}) + +window.onbeforeunload = function () { + return '' +} + +function t (a) { + switch (a) { + case 1: + client.addTorrent('https://webtorrent.io/torrents/sintel.torrent', {}) + break + case 2: + client.addTorrent('https://webtorrent.io/torrents/tears-of-steel.torrent', {}) + break + case 3: + client.addTorrent('magnet:?xt=urn:btih:CE9156EB497762F8B7577B71C0647A4B0C3423E1&dn=Inception+%282010%29+720p+-+mkv+-+1.0GB+-+YIFY&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969%2Fannounce&tr=udp%3A%2F%2F9.rarbg.to%3A2920%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Ftracker.internetwarriors.net%3A1337%2Fannounce&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.pirateparty.gr%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.cyberia.is%3A6969%2Fannounce', {}) + break + } +} diff --git a/app/js/playerHandler.js b/app/js/playerHandler.js deleted file mode 100644 index a4b9a0d..0000000 --- a/app/js/playerHandler.js +++ /dev/null @@ -1,698 +0,0 @@ -const controls = document.getElementsByClassName('ctrl') - -for (const item of controls) { - item.addEventListener('click', function () { - const func = this.dataset.name - window[func]() - }) -} - -// video element shit -video.addEventListener('playing', resetBuffer) -video.addEventListener('canplay', resetBuffer) -video.onloadedmetadata = () => { - initThumbnail() - updateDisplay(); - (video.audioTracks && video.audioTracks.length > 1) ? baudio.removeAttribute('disabled') : baudio.setAttribute('disabled', '') -} -video.onended = () => { - updateBar(video.currentTime / video.duration * 100) - if (settings.player6 && parseInt(playerData.nowPlaying[1]) < playerData.nowPlaying[0].episodes) btnnext() -} -video.addEventListener('waiting', isBuffering) -video.ontimeupdate = () => { - updateDisplay() - checkCompletion() - if ('setPositionState' in navigator.mediaSession) updatePositionState() -} - -if (!('pictureInPictureEnabled' in document)) { - video.setAttribute('disablePictureInPicture', '') - bpip.setAttribute('disabled', '') -} else { - bpip.removeAttribute('disabled') - video.addEventListener('enterpictureinpicture', () => { if (playerData.octopusInstance) btnpip() }) -} - -let playerData = {} - -function cleanupVideo () { // cleans up objects, attemps to clear as much video caching as possible - if (playerData.octopusInstance) playerData.octopusInstance.dispose() - if (playerData.fonts) playerData.fonts.forEach(file => URL.revokeObjectURL(file)) - if (dl.href) URL.revokeObjectURL(dl.href) - dl.setAttribute('disabled', '') - dl.onclick = undefined - video.poster = '' - // some attemt at cache clearing - video.pause() - video.src = '' - video.load() - document.title = 'Miru' - progress.value = 0 - // if (typeof client !== 'undefined' && client.torrents[0] && client.torrents[0].files.length > 1) { - // client.torrents[0].files.forEach(file => file.deselect()); - // client.torrents[0].deselect(0, client.torrents[0].pieces.length - 1, false); - // console.log(videoFiles.filter(file => `${scope}webtorrent/${client.torrents[0].infoHash}/${encodeURI(file.path)}` == video.src)) - // look for file and delete its store - // } - playerData = { - subtitles: [], - fonts: [], - headers: [] - } - nowPlayingDisplay.innerHTML = '' - bcap.setAttribute('disabled', '') - bpl.setAttribute('disabled', '') - bnext.removeAttribute('disabled') - navNowPlaying.classList.add('d-none') - if ('mediaSession' in navigator) navigator.mediaSession.metadata = undefined -} - -async function buildVideo (torrent, opts) { // sets video source and creates a bunch of other media stuff - // play wanted episode from opts, or the 1st episode, or 1st file [batches: plays wanted episode, single: plays the only episode, manually added: plays first or only file] - let selectedFile = videoFiles[0] - if (opts.file) { - selectedFile = opts.file - } else if (videoFiles.length > 1) { - // TODO play selected media too! - selectedFile = videoFiles.filter(async file => await anitomyscript(file.name).then(object => Number(object.episode_number) === Number(opts.episode || 1)))[0] || videoFiles[0] - } - video.src = `${scope}webtorrent/${torrent.infoHash}/${encodeURI(selectedFile.path)}` - video.load() - // "predict" video FPS for subtitle renderer - playerData.fps = new Promise((resolve, reject) => { - if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) { - let wasSeeked - video.onseeking = function () { wasSeeked = true } - video.onplay = () => setTimeout(() => video.requestVideoFrameCallback((now, metadata) => { - const rawFPS = metadata.presentedFrames / metadata.mediaTime - console.log(rawFPS, metadata) - if (!wasSeeked) { - if (rawFPS >= 19 && rawFPS <= 26) { - resolve(23.976) - } else if (rawFPS > 26 && rawFPS <= 35) { - resolve(29.97) - } else if (rawFPS > 50 && rawFPS <= 70) { - resolve(59.94) - } else { - // smth went VERY wrong XD - resolve(23.976) - } - } else { - // video was seeked, cant predict fps - resolve(23.976) - } - video.onseeking = undefined - video.onplay = undefined - }), 3000) - } else { - // can't predict fps, API unsupported, assume 24fps - resolve(23.976) - } - }) - playVideo() - - if (videoFiles.length > 1) bpl.removeAttribute('disabled') - - async function processFile () { - halfmoon.initStickyAlert({ - content: `${selectedFile.name} has finished downloading. Now seeding.`, - title: 'Download Complete', - alertType: 'alert-success', - fillType: '' - }) - await postDownload(selectedFile) - if (settings.player5) { - finishThumbnails(`${scope}webtorrent/${torrent.infoHash}/${encodeURI(selectedFile.path)}`) - } - if (!torrent.store.store._store) { // only allow download from RAM - downloadFile(selectedFile) - } - } - if (selectedFile.done) { - processFile() - } else { - playerData.onDone = selectedFile.on('done', () => { - processFile() - }) - } - playerData.onProgress = () => { - if (document.location.hash === '#player') { - if (!player.classList.contains('immersed')) { - player.style.setProperty('--download', selectedFile.progress * 100 + '%') - peers.innerHTML = torrent.numPeers - downSpeed.innerHTML = prettyBytes(torrent.downloadSpeed) + '/s' - upSpeed.innerHTML = prettyBytes(torrent.uploadSpeed) + '/s' - } - } - setTimeout(playerData.onProgress, 100) - } - setTimeout(playerData.onProgress, 100) - - if (opts.media && videoFiles.length === 1) { - // if this is a single file, then the media is most likely accurate, just update it! - playerData.nowPlaying = [await alRequest({ id: opts.media?.id, method: 'SearchIDSingle' }).then(res => res.data.Media), opts.episode || 1] - // update store with entry, but dont really do anything with it - resolveFileMedia({ fileName: selectedFile.name, method: 'SearchName' }) - } else { - // if this is a batch or single unresolved file, then resolve the single selected file, batches can include specials - const mediaInformation = await resolveFileMedia({ fileName: selectedFile.name, method: 'SearchName' }) - playerData.nowPlaying = [mediaInformation.media, mediaInformation.episode || 1] - } - let mediaMetadata - // only set mediasession and other shit if the playerdata is parsed correctly - if (playerData.nowPlaying[0]) { - navNowPlaying.classList.remove('d-none') - mediaMetadata = new MediaMetadata({ - title: playerData.nowPlaying[0].title.userPreferred, - artist: `Episode ${Number(playerData.nowPlaying[1])}`, - album: 'Miru', - artwork: [{ - src: playerData.nowPlaying[0].coverImage.medium, - sizes: '256x256', - type: 'image/jpg' - }] - }) - if (parseInt(playerData.nowPlaying[1]) >= playerData.nowPlaying[0].episodes) bnext.setAttribute('disabled', '') - let streamingEpisode - if (playerData.nowPlaying[0].streamingEpisodes.length >= Number(playerData.nowPlaying[1])) { - streamingEpisode = playerData.nowPlaying[0].streamingEpisodes.filter(episode => episodeRx.exec(episode.title) && Number(episodeRx.exec(episode.title)[1]) === Number(playerData.nowPlaying[1]))[0] - } - // TODO: this should also use absolute episode numbers instead of relative but AL will change this anyways.... - if (streamingEpisode) { - video.poster = streamingEpisode.thumbnail - document.title = `${playerData.nowPlaying[0].title.userPreferred} - EP ${Number(playerData.nowPlaying[1])} - ${episodeRx.exec(streamingEpisode.title)[2]} - Miru` - mediaMetadata.artist = `Episode ${Number(playerData.nowPlaying[1])} - ${episodeRx.exec(streamingEpisode.title)[2]}` - mediaMetadata.artwork = [{ - src: streamingEpisode.thumbnail, - sizes: '256x256', - type: 'image/jpg' - }] - nowPlayingDisplay.innerHTML = `EP ${Number(playerData.nowPlaying[1])} - ${episodeRx.exec(streamingEpisode.title)[2]}` - } else { - document.title = `${playerData.nowPlaying[0].title.userPreferred} - EP ${Number(playerData.nowPlaying[1])} - Miru` - nowPlayingDisplay.innerHTML = `EP ${Number(playerData.nowPlaying[1])}` - } - } - if ('mediaSession' in navigator && mediaMetadata) navigator.mediaSession.metadata = mediaMetadata -} - -// visibility loss pause -if (settings.player10) { - document.addEventListener('visibilitychange', () => { - if (!video.ended) document.visibilityState === 'hidden' ? video.pause() : playVideo() - }) -} - -// progress seek bar and display - -progress.addEventListener('input', dragBar) -progress.addEventListener('mouseup', dragBarEnd) -progress.addEventListener('touchend', dragBarEnd) -progress.addEventListener('click', dragBarEnd) -progress.addEventListener('mousedown', dragBarStart) - -function updateDisplay () { - if (!player.classList.contains('immersed') && document.location.hash === '#player') { - progress.style.setProperty('--buffer', video.buffered.length === 0 ? 0 : video.buffered.end(video.buffered.length - 1) / video.duration * 100 + '%') - updateBar((video.currentTime / video.duration * 100) || progress.value) - } - createThumbnail(video) -} - -function dragBar () { - updateBar(progress.value) - thumb.src = playerData.thumbnailData.thumbnails[Math.floor(currentTime / playerData.thumbnailData.interval)] || ' ' -} - -function dragBarEnd () { - video.currentTime = currentTime || 0 - playVideo() -} - -async function dragBarStart () { - await video.pause() - updateBar(progress.value) -} - -let currentTime = 0 -function updateBar (progressPercent) { - progress.style.setProperty('--progress', progressPercent + '%') - thumb.style.setProperty('--progress', progressPercent + '%') - currentTime = video.duration * progressPercent / 100 - elapsed.innerHTML = toTS(currentTime) - remaining.innerHTML = toTS(video.duration - currentTime) - progress.value = progressPercent - progress.setAttribute('data-ts', toTS(currentTime)) -} - -// dynamic thumbnail previews - -function initThumbnail () { - const canvas = document.createElement('canvas') - playerData.thumbnailData = { - canvas: canvas, - context: canvas.getContext('2d'), - height: parseInt(150 / (video.videoWidth / video.videoHeight)), - thumbnails: [], - interval: video.duration / 300 < 5 ? 5 : video.duration / 300 - } - canvas.width = 150 - canvas.height = playerData.thumbnailData.height - thumb.style.setProperty('--height', playerData.thumbnailData.height + 'px') -} - -function createThumbnail (vid) { - if (vid?.readyState >= 2) { - const index = Math.floor(vid.currentTime / playerData.thumbnailData.interval) - if (!playerData.thumbnailData.thumbnails[index]) { - playerData.thumbnailData.context.fillRect(0, 0, 150, playerData.thumbnailData.height) - playerData.thumbnailData.context.drawImage(vid, 0, 0, 150, playerData.thumbnailData.height) - playerData.thumbnailData.thumbnails[index] = playerData.thumbnailData.canvas.toDataURL('image/jpeg') - } - } -} - -function finishThumbnails (src) { - const t0 = performance.now() - playerData.thumbnailData.video = document.createElement('video') - let index = 0 - playerData.thumbnailData.video.preload = 'none' - playerData.thumbnailData.video.volume = 0 - playerData.thumbnailData.video.playbackRate = 0 - playerData.thumbnailData.video.width = playerData.thumbnailData.canvas.width - playerData.thumbnailData.video.addEventListener('loadeddata', loadTime) - playerData.thumbnailData.video.addEventListener('canplay', () => { - createThumbnail(playerData.thumbnailData?.video) - loadTime() - }) - function loadTime () { - while (playerData.thumbnailData?.thumbnails[index] && index <= Math.floor(playerData.thumbnailData.video.duration / playerData.thumbnailData.interval)) { // only create thumbnails that are missing - index++ - } - if (playerData.thumbnailData?.video?.currentTime !== playerData.thumbnailData?.video?.duration) { - playerData.thumbnailData.video.currentTime = index * playerData.thumbnailData.interval - } else { - playerData.thumbnailData?.video?.removeAttribute('src') - playerData.thumbnailData?.video?.load() - playerData.thumbnailData?.video?.remove() - delete playerData.thumbnailData?.video - console.log('Thumbnail creating finished', index, performance.now() - t0) - } - index++ - } - playerData.thumbnailData.video.src = src - playerData.thumbnailData.video.play() - console.log('Thumbnail creating started') -} - -// file download -function downloadFile (file) { - dl.removeAttribute('disabled') - dl.onclick = async e => { - file.getBlobURL((_err, url) => { - const a = document.createElement('a') - a.download = file.name - a.href = url - document.body.appendChild(a) - a.click(e) - a.remove() - window.URL.revokeObjectURL(url) - }) - } -} - -// bufering spinner - -let buffer -function resetBuffer () { - if (buffer) { - clearTimeout(buffer) - buffer = undefined - buffering.classList.add('hidden') - } -} - -function isBuffering () { - buffer = setTimeout(displayBuffer, 150) -} - -function displayBuffer () { - buffering.classList.remove('hidden') - resetTimer() -} - -// immerse timeout -let immerseTime - -player.onmousemove = resetTimer -player.onkeypress = resetTimer -function immersePlayer () { - player.classList.add('immersed') - immerseTime = undefined -} - -function resetTimer () { - if (!immerseTime) { - clearTimeout(immerseTime) - player.classList.remove('immersed') - immerseTime = setTimeout(immersePlayer, parseInt(settings.player2) * 1000) - } -} - -function toTS (sec) { - if (Number.isNaN(sec) || sec < 0) { - return '00:00' - } - - const hours = Math.floor(sec / 3600) - let minutes = Math.floor((sec - (hours * 3600)) / 60) - let seconds = Math.floor(sec - (hours * 3600) - (minutes * 60)) - - if (minutes < 10) { - minutes = `0${minutes}` - } - - if (seconds < 10) { - seconds = `0${seconds}` - } - - if (hours > 0) { - return `${hours}:${minutes}:${seconds}` - } else { - return `${minutes}:${seconds}` - } - // return new Date(sec*1000).toISOString().slice(12, -1).slice(0, -4).replace(/^0:/,"") // laggy :/ -} - -// play/pause button -ptoggle.addEventListener('click', btnpp) -async function playVideo () { - try { - await video.play() - bpp.innerHTML = 'pause' - } catch (err) { - bpp.innerHTML = 'play_arrow' - } -} - -function btnpp () { - if (video.paused) { - playVideo() - } else { - bpp.innerHTML = 'play_arrow' - video.pause() - } -} -// next video button -let nextCooldown -function btnnext () { - clearTimeout(nextCooldown) - nextCooldown = setTimeout(() => { - const currentFile = videoFiles.filter(file => `${window.location.origin}${scope}webtorrent/${client.torrents[0].infoHash}/${encodeURI(file.path)}` === video.src)[0] - if (videoFiles.length > 1 && videoFiles.indexOf(currentFile) < videoFiles.length - 1) { - const fileIndex = videoFiles.indexOf(currentFile) + 1 - const nowPlaying = [playerData.nowPlaying[0], parseInt(playerData.nowPlaying[1]) + 1] - cleanupVideo() - buildVideo(videoFiles[fileIndex], nowPlaying) - } else { - if (playerData.nowPlaying[0]) { - nyaaSearch(playerData.nowPlaying[0], parseInt(playerData.nowPlaying[1]) + 1) - } else { - halfmoon.initStickyAlert({ - content: 'Couldn\'t find anime name! Try specifying a torrent manually.', - title: 'Search Failed', - alertType: 'alert-danger', - fillType: '' - }) - } - } - }, 200) -} -// volume shit -volume.addEventListener('input', () => updateVolume()) -let oldlevel - -function btnmute () { - if (video.volume === 0) { - updateVolume(oldlevel) - } else { - oldlevel = video.volume * 100 - updateVolume(0) - } -} - -function updateVolume (a) { - let level - if (a == null || isNaN(a)) { - level = Number(volume.value) - } else { - level = a - volume.value = a - } - volume.style.setProperty('--volume-level', level + '%') - bmute.innerHTML = (level === 0) ? 'volume_off' : 'volume_up' - video.volume = level / 100 -} -updateVolume(parseInt(settings.volume)) - -// PiP - -async function btnpip () { - if (video.readyState) { - if (!playerData.octopusInstance) { - video !== document.pictureInPictureElement ? await video.requestPictureInPicture() : await document.exitPictureInPicture() - } else { - if (document.pictureInPictureElement && !document.pictureInPictureElement.id) { // only exit if pip is the custom one, else overwrite existing pip with custom - await document.exitPictureInPicture() - } else { - const canvas = document.createElement('canvas') - const canvasVideo = document.createElement('video') - const context = canvas.getContext('2d', { alpha: false }) - let running = true - canvas.width = video.videoWidth - canvas.height = video.videoHeight - - function renderFrame () { - if (running === true) { - context.drawImage(video, 0, 0) - context.drawImage(subtitleCanvas, 0, 0, canvas.width, canvas.height) - window.requestAnimationFrame(renderFrame) - } - } - canvasVideo.srcObject = canvas.captureStream() - canvasVideo.onloadedmetadata = () => { - canvasVideo.play() - canvasVideo.requestPictureInPicture().then( - player.classList.add('pip') - ).catch(e => { - console.warn('Failed To Burn In Subtitles ' + e) - running = false - canvasVideo.remove() - canvas.remove() - player.classList.remove('pip') - }) - } - canvasVideo.onleavepictureinpicture = () => { - running = false - canvasVideo.remove() - canvas.remove() - player.classList.remove('pip') - } - window.requestAnimationFrame(renderFrame) - } - } - } -} - -// theathe mode - -function btntheatre () { - pageWrapper.classList.toggle('nav-hidden') -} - -// fullscreen -player.addEventListener('fullscreenchange', updateFullscreen) -ptoggle.addEventListener('dblclick', btnfull) -function btnfull () { - document.fullscreenElement ? document.exitFullscreen() : player.requestFullscreen() -} -function updateFullscreen () { - document.fullscreenElement ? bfull.innerHTML = 'fullscreen_exit' : bfull.innerHTML = 'fullscreen' -} - -// seeking and skipping - -function seek (a) { - if (a === 85 && video.currentTime < 10) { - video.currentTime = 90 - } else if (a === 85 && (video.duration - video.currentTime) < 90) { - video.currentTime = video.duration - } else { - video.currentTime += a - } - updateBar(video.currentTime / video.duration * 100) -} -// subtitles, generates content every single time its opened because fuck knows when the parser will find new shit -// this needs to go.... really badly -function btncap () { - const frag = document.createDocumentFragment() - const off = document.createElement('a') - off.classList.add('dropdown-item', 'pointer') - playerData.selectedHeader ? off.classList.add('text-muted') : off.classList.add('text-white') - off.innerHTML = 'OFF' - off.onclick = () => { - renderSubs() - playerData.selectedHeader = undefined - btncap() - } - frag.appendChild(off) - for (const track of playerData.headers) { - if (track) { - const template = document.createElement('a') - template.classList.add('dropdown-item', 'pointer', 'text-capitalize') - template.innerHTML = (track.language || (!Object.values(playerData.headers).some(header => header.language === 'eng' || header.language === 'en') ? 'eng' : header.type)) + (track.name ? ' - ' + track.name : '') - if (playerData.selectedHeader === track.number) { - template.classList.add('text-white') - } else { - template.classList.add('text-muted') - } - template.onclick = () => { - renderSubs(track.number) - playerData.selectedHeader = track.number - btncap() - } - frag.appendChild(template) - } - } - const timeOffset = document.createElement('div') - timeOffset.classList.add('btn-group', 'w-full', 'pt-5') - timeOffset.setAttribute('role', 'group') - timeOffset.innerHTML = ` -` - frag.appendChild(timeOffset) - subMenu.innerHTML = '' - subMenu.appendChild(frag) -} -// playlist - -function btnpl () { - window.location.hash = '#playlist' -} - -// audio tracks - -function btnaudio () { - const frag = document.createDocumentFragment() - for (const track of video.audioTracks) { - const template = document.createElement('a') - template.classList.add('dropdown-item', 'pointer', 'text-capitalize') - template.innerHTML = (track.language || (!Object.values(video.audioTracks).some(track => track.language === 'eng' || track.language === 'en') ? 'eng' : track.label)) + (track.label ? ' - ' + track.label : '') - track.enabled === true ? template.classList.add('text-white') : template.classList.add('text-muted') - template.onclick = () => { - selectAudio(track.id) - } - frag.appendChild(template) - } - - audioTracksMenu.innerHTML = '' - audioTracksMenu.appendChild(frag) -} -function selectAudio (id) { - for (const track of video.audioTracks) { - track.id === id ? track.enabled = true : track.enabled = false - } - seek(-1) // stupid fix because video freezes up when chaging tracks - btnaudio() -} -// keybinds - -document.onkeydown = a => { - if (a.key === 'F5') { - a.preventDefault() - } - if (document.location.hash === '#player') { - switch (a.key) { - case ' ': - btnpp() - break - case 'n': - btnnext() - break - case 'm': - btnmute() - break - case 'p': - btnpip() - break - case 't': - btntheatre() - break - case 'c': - btncap() - break - case 'f': - btnfull() - break - case 's': - seek(85) - break - case 'ArrowLeft': - seek(-parseInt(settings.player3)) - break - case 'ArrowRight': - seek(parseInt(settings.player3)) - break - case 'ArrowUp': - updateVolume(parseInt(volume.value) + 5) - break - case 'ArrowDown': - updateVolume(parseInt(volume.value) - 5) - break - case 'Escape': - document.location.hash = '#home' - break - } - } -} -// media session shit - -function updatePositionState () { - if (video.duration) { - navigator.mediaSession.setPositionState({ - duration: video.duration || 0, - playbackRate: video.playbackRate || 0, - position: video.currentTime || 0 - }) - } -} - -if ('mediaSession' in navigator) { - navigator.mediaSession.setActionHandler('play', btnpp) - navigator.mediaSession.setActionHandler('pause', btnpp) - navigator.mediaSession.setActionHandler('seekbackward', () => { - seek(-parseInt(settings.player3)) - }) - navigator.mediaSession.setActionHandler('seekforward', () => { - seek(parseInt(settings.player3)) - }) - navigator.mediaSession.setActionHandler('nexttrack', btnnext) -} - -// AL entry auto add -function checkCompletion () { - if (!playerData.watched && video.duration - 180 < video.currentTime && playerData.nowPlaying && (playerData.nowPlaying[0].episodes || playerData.nowPlaying[0].nextAiringEpisode.episode)) { - if (settings.other2 && !(!(playerData.nowPlaying[0].episodes || playerData.nowPlaying[0].nextAiringEpisode.episode) && playerData.nowPlaying[0].streamingEpisodes.length && parseInt(playerData.nowPlaying[1] > 12))) { - alEntry() - } else { - halfmoon.initStickyAlert({ - content: `Do You Want To Mark
${playerData.nowPlaying[0].title.userPreferred}
Episode ${playerData.nowPlaying[1]} As Completed?
- - `, - title: 'Episode Complete', - timeShown: 180000 - }) - } - playerData.watched = true - } -} diff --git a/app/js/settingsHandler.js b/app/js/settingsHandler.js index 9d14c23..c86f7fd 100644 --- a/app/js/settingsHandler.js +++ b/app/js/settingsHandler.js @@ -1,5 +1,5 @@ const settingsElements = [ - volume, player2, player3, player5, player6, player10, subtitle1, subtitle3, torrent1, torrent2, torrent3, torrent4, torrent5, torrent6, torrent7, torrent9, other1, other2 + volume, player2, player3, player5, player6, player10, subtitle1, subtitle3, torrent1, torrent2, torrent3, torrent4, torrent5, torrent6, torrent7, torrent8, torrent9, other1, other2 ] setRes.addEventListener('click', restoreDefaults) settingsTab.addEventListener('click', applySettingsTimeout) diff --git a/app/js/subtitleOctopus.js b/app/js/subtitleOctopus.js deleted file mode 100644 index 20aebf5..0000000 --- a/app/js/subtitleOctopus.js +++ /dev/null @@ -1,123 +0,0 @@ -const { SubtitleStream } = MatroskaSubtitles -const { SubtitleParser } = MatroskaSubtitles - -function subStream (stream) { // subtitle parsing with seeking support - if (playerData.subtitleStream) { - playerData.subtitleStream = new SubtitleStream(playerData.subtitleStream) - } else { - playerData.subtitleStream = new SubtitleStream() - playerData.subtitleStream.once('tracks', pTracks => { - bcap.removeAttribute('disabled') - playerData.headers = [] - pTracks.forEach(track => { - if (track.type !== 'ass') { // overwrite webvtt header with custom one - track.header = `[V4+ Styles] -Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: Default,${Object.values(subtitle1list.options).filter(item => item.value === settings.subtitle1)[0].innerText} -[Events] - -` - } - playerData.headers[track.number] = track - playerData.subtitles[track.number] = new Set() - if (!playerData.selectedHeader) playerData.selectedHeader = track.number - }) - }) - } - playerData.subtitleStream.on('subtitle', (subtitle, trackNumber) => { - if (playerData.headers && !playerData.parsed) { - if (playerData.headers[trackNumber].type === 'webvtt') convertSub(subtitle) - const formatSub = 'Dialogue: ' + (subtitle.layer || 0) + ',' + new Date(subtitle.time).toISOString().slice(12, -1).slice(0, -1) + ',' + new Date(subtitle.time + subtitle.duration).toISOString().slice(12, -1).slice(0, -1) + ',' + (subtitle.style || 'Default') + ',' + (subtitle.name || '') + ',' + (subtitle.marginL || '0') + ',' + (subtitle.marginR || '0') + ',' + (subtitle.marginV || '0') + ',' + (subtitle.effect || '') + ',' + subtitle.text - playerData.subtitles[trackNumber].add(formatSub) - if (playerData.selectedHeader === trackNumber) renderSubs(trackNumber) - } - }) - playerData.subtitleStream.on('file', file => { - if (file.mimetype === 'application/x-truetype-font' || file.mimetype === 'application/font-woff') playerData.fonts.push(window.URL.createObjectURL(new Blob([file.data], { type: file.mimetype }))) - }) - stream.pipe(playerData.subtitleStream) -} -let octopusTimeout -async function renderSubs (trackNumber) { - if (!playerData.octopusInstance) { - const options = { - video: video, - targetFps: await playerData.fps, - subContent: trackNumber ? playerData.headers[trackNumber].header.slice(0, -1) + Array.from(playerData.subtitles[trackNumber]).join('\n') : playerData.headers[3].header.slice(0, -1), - renderMode: 'offscreenCanvas', - fonts: playerData.fonts?.length !== 0 ? playerData.fonts : ['https://fonts.gstatic.com/s/roboto/v20/KFOlCnqEu92Fr1MmEU9fBBc4.woff2'], - workerUrl: 'js/subtitles-octopus-worker.js', - timeOffset: 0, - onReady: function () { - if (!video.paused) { - video.pause() - playVideo() - } - } - } - if (!playerData.octopusInstance) playerData.octopusInstance = new SubtitlesOctopus(options) - } else { - if (!octopusTimeout) { - octopusTimeout = setTimeout(() => { - octopusTimeout = undefined - if (playerData.octopusInstance) playerData.octopusInstance.setTrack(trackNumber ? playerData.headers[trackNumber].header.slice(0, -1) + Array.from(playerData.subtitles[trackNumber]).join('\n') : playerData.headers[3].header.slice(0, -1)) - }, 1000) - } - } -} -function convertSub (subtitle) { // converts vtt subtitles to ssa ones - const matches = subtitle.text.match(/<[^>]+>/g) // create array of all tags - if (matches) { - matches.forEach(match => { - if (/<\//.test(match)) { // check if its a closing tag - subtitle.text = subtitle.text.replace(match, match.replace('', '0}')) - } else { - subtitle.text = subtitle.text.replace(match, match.replace('<', '{\\').replace('>', '1}')) - } - }) - } - // replace all html special tags with normal ones - subtitle.text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/ /g, '\\h') -} -function postDownload (file) { // parse subtitles fully after a download is finished - return new Promise((resolve, reject) => { - if (file.name.endsWith('.mkv') || file.name.endsWith('.webm')) { - let parser = new SubtitleParser() - parser.once('tracks', pTracks => { - pTracks.forEach(track => { - if (track.type !== 'ass') { // overwrite webvtt header with custom one - track.header = `[V4+ Styles] -Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding -Style: Default,${Object.values(subtitle1list.options).filter(item => item.value === settings.subtitle1)[0].innerText} -[Events] - -` - } - playerData.headers[track.number] = track - if (!playerData.subtitles[track.number]) playerData.subtitles[track.number] = new Set() - if (!playerData.selectedHeader) playerData.selectedHeader = track.number - }) - }) - parser.on('subtitle', (subtitle, trackNumber) => { - if (playerData.headers[trackNumber].type === 'webvtt') convertSub(subtitle) - playerData.subtitles[trackNumber].add('Dialogue: ' + (subtitle.layer || 0) + ',' + new Date(subtitle.time).toISOString().slice(12, -1).slice(0, -1) + ',' + new Date(subtitle.time + subtitle.duration).toISOString().slice(12, -1).slice(0, -1) + ',' + (subtitle.style || 'Default') + ',' + (subtitle.name || '') + ',' + (subtitle.marginL || '0') + ',' + (subtitle.marginR || '0') + ',' + (subtitle.marginV || '0') + ',' + (subtitle.effect || '') + ',' + subtitle.text) - }) - parser.on('finish', () => { - console.log('Sub parsing finished') - playerData.parsed = 1 - playerData.subtitleStream = undefined - renderSubs(playerData.selectedHeader) - parser = undefined - bcap.removeAttribute('disabled') - if (!video.paused) { - video.pause() - playVideo() - } - resolve() - }) - console.log('Sub parsing started') - playerData.subtitlePraseStream = file.createReadStream().pipe(parser) - // when this gets overwritten the parser stays so it might "leak" some RAM??? - } - }) -} diff --git a/app/js/subtitles-octopus.js b/app/js/subtitles-octopus.js index bdbf55e..d408cf9 100644 --- a/app/js/subtitles-octopus.js +++ b/app/js/subtitles-octopus.js @@ -1,964 +1,963 @@ -const SubtitlesOctopus = function (options) { - let supportsWebAssembly = false - try { - if (typeof WebAssembly === 'object' && - typeof WebAssembly.instantiate === 'function') { - const module = new WebAssembly.Module(Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00)) - if (module instanceof WebAssembly.Module) { supportsWebAssembly = (new WebAssembly.Instance(module) instanceof WebAssembly.Instance) } - } - } catch (e) { - } - console.log('WebAssembly support detected: ' + (supportsWebAssembly ? 'yes' : 'no')) - - const self = this - self.canvas = options.canvas // HTML canvas element (optional if video specified) - self.renderMode = options.renderMode || (options.lossyRender ? 'fast' : (options.blendRender ? 'blend' : 'normal')) - - // play with those when you need some speed, e.g. for slow devices - self.dropAllAnimations = options.dropAllAnimations || false - self.libassMemoryLimit = options.libassMemoryLimit || 0 // set libass bitmap cache memory limit in MiB (approximate) - self.libassGlyphLimit = options.libassGlyphLimit || 0 // set libass glyph cache memory limit in MiB (approximate) - self.targetFps = options.targetFps || 23.976 - self.prescaleTradeoff = options.prescaleTradeoff || null // render subtitles less than viewport when less than 1.0 to improve speed, render to more than 1.0 to improve quality; set to null to disable scaling - self.softHeightLimit = options.softHeightLimit || 1080 // don't apply prescaleTradeoff < 1 when viewport height is less that this limit - self.hardHeightLimit = options.hardHeightLimit || 2160 // don't ever go above this limit - self.resizeVariation = options.resizeVariation || 0.2 // by how many a size can vary before it would cause clearance of prerendered buffer - - self.renderAhead = options.renderAhead || 0 // how many MiB to render ahead and store; 0 to disable (approximate) - self.isOurCanvas = false // (internal) we created canvas and manage it - self.video = options.video // HTML video element (optional if canvas specified) - self.canvasParent = null // (internal) HTML canvas parent element - self.fonts = options.fonts || [] // Array with links to fonts used in sub (optional) - self.availableFonts = options.availableFonts || [] // Object with all available fonts (optional). Key is font name in lower case, value is link: {"arial": "/font1.ttf"} - self.onReadyEvent = options.onReady // Function called when SubtitlesOctopus is ready (optional) - if (supportsWebAssembly) { - self.workerUrl = options.workerUrl || 'subtitles-octopus-worker.js' // Link to WebAssembly worker - } else { - self.workerUrl = options.legacyWorkerUrl || 'subtitles-octopus-worker-legacy.js' // Link to legacy worker - } - self.subUrl = options.subUrl // Link to sub file (optional if subContent specified) - self.subContent = options.subContent || null // Sub content (optional if subUrl specified) - self.onErrorEvent = options.onError // Function called in case of critical error meaning sub wouldn't be shown and you should use alternative method (for instance it occurs if browser doesn't support web workers). - self.debug = options.debug || false // When debug enabled, some performance info printed in console. - self.lastRenderTime = 0 // (internal) Last time we got some frame from worker - self.pixelRatio = window.devicePixelRatio || 1 // (internal) Device pixel ratio (for high dpi devices) - - self.timeOffset = options.timeOffset || 0 // Time offset would be applied to currentTime from video (option) - - self.renderedItems = [] // used to store items rendered ahead when renderAhead > 0 - self.renderAhead = self.renderAhead * 1024 * 1024 * 0.9 /* try to eat less than requested */ - self.oneshotState = { - eventStart: null, - eventOver: false, - iteration: 0, - renderRequested: false, - requestNextTimestamp: -1, - prevWidth: null, - prevHeight: null - } - - self.hasAlphaBug = false; - - (function () { - if (typeof ImageData.prototype.constructor === 'function') { - try { - // try actually calling ImageData, as on some browsers it's reported - // as existing but calling it errors out as "TypeError: Illegal constructor" - const test = new window.ImageData(new Uint8ClampedArray([0, 0, 0, 0]), 1, 1) - return - } catch (e) { - console.log('detected that ImageData is not constructable despite browser saying so') +class SubtitlesOctopus { + constructor (options) { + let supportsWebAssembly = false + try { + if (typeof WebAssembly === 'object' && + typeof WebAssembly.instantiate === 'function') { + const module = new WebAssembly.Module(Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00)) + if (module instanceof WebAssembly.Module) { supportsWebAssembly = (new WebAssembly.Instance(module) instanceof WebAssembly.Instance) } } + } catch (e) { } + console.log('WebAssembly support detected: ' + (supportsWebAssembly ? 'yes' : 'no')) - const canvas = document.createElement('canvas') - const ctx = canvas.getContext('2d') + const self = this + self.canvas = options.canvas // HTML canvas element (optional if video specified) + self.renderMode = options.renderMode || (options.lossyRender ? 'fast' : (options.blendRender ? 'blend' : 'normal')) - window.ImageData = function () { - let i = 0 - let data - if (arguments[0] instanceof Uint8ClampedArray) { - data = arguments[i++] - } - const width = arguments[i++] - const height = arguments[i] + // play with those when you need some speed, e.g. for slow devices + self.dropAllAnimations = options.dropAllAnimations || false + self.libassMemoryLimit = options.libassMemoryLimit || 0 // set libass bitmap cache memory limit in MiB (approximate) + self.libassGlyphLimit = options.libassGlyphLimit || 0 // set libass glyph cache memory limit in MiB (approximate) + self.targetFps = options.targetFps || 23.976 + self.prescaleTradeoff = options.prescaleTradeoff || null // render subtitles less than viewport when less than 1.0 to improve speed, render to more than 1.0 to improve quality; set to null to disable scaling + self.softHeightLimit = options.softHeightLimit || 1080 // don't apply prescaleTradeoff < 1 when viewport height is less that this limit + self.hardHeightLimit = options.hardHeightLimit || 2160 // don't ever go above this limit + self.resizeVariation = options.resizeVariation || 0.2 // by how many a size can vary before it would cause clearance of prerendered buffer - const imageData = ctx.createImageData(width, height) - if (data) imageData.data.set(data) - return imageData - } - })() - - self.workerError = function (error) { - console.error('Worker error: ', error) - if (self.onErrorEvent) { - self.onErrorEvent(error) - } - if (!self.debug) { - self.dispose() - throw new Error('Worker error: ' + error) - } - } - - // Not tested for repeated usage yet - self.init = function () { - if (!window.Worker) { - self.workerError('worker not supported') - return - } - // Worker - if (!self.worker) { - self.worker = new Worker(self.workerUrl) - self.worker.onmessage = self.onWorkerMessage - self.worker.onerror = self.workerError - } - self.workerActive = false - self.createCanvas() - self.setVideo(options.video) - self.setSubUrl(options.subUrl) - self.worker.postMessage({ - target: 'worker-init', - width: self.canvas.width, - height: self.canvas.height, - URL: document.URL, - currentScript: self.workerUrl, - preMain: true, - renderMode: self.renderMode, - subUrl: self.subUrl, - subContent: self.subContent, - fonts: self.fonts, - availableFonts: self.availableFonts, - debug: self.debug, - targetFps: self.targetFps, - libassMemoryLimit: self.libassMemoryLimit, - libassGlyphLimit: self.libassGlyphLimit, - renderOnDemand: self.renderAhead > 0, - dropAllAnimations: self.dropAllAnimations - }) - if (self.renderMode === 'offscreenCanvas') { - self.pushOffscreenCanvas() - self.initDone = true - } - } - self.pushOffscreenCanvas = function () { - const canvasControl = self.canvas.transferControlToOffscreen() - self.worker.postMessage({ - target: 'offscreenCanvas', - canvas: canvasControl - }, [canvasControl]) - } - - self.createCanvas = function () { - if (self.video) { - self.isOurCanvas = true - self.canvas = document.createElement('canvas') - self.canvas.className = 'libassjs-canvas' - self.canvas.style.display = 'none' - self.canvas.id = 'subtitleCanvas' - - self.canvasParent = document.createElement('div') - self.canvasParent.className = 'libassjs-canvas-parent' - self.canvasParent.appendChild(self.canvas) - - if (self.video.nextSibling) { - self.video.parentNode.insertBefore(self.canvasParent, self.video.nextSibling) - } else { - self.video.parentNode.appendChild(self.canvasParent) - } + self.renderAhead = options.renderAhead || 0 // how many MiB to render ahead and store; 0 to disable (approximate) + self.isOurCanvas = false // (internal) we created canvas and manage it + self.video = options.video // HTML video element (optional if canvas specified) + self.canvasParent = null // (internal) HTML canvas parent element + self.fonts = options.fonts || [] // Array with links to fonts used in sub (optional) + self.availableFonts = options.availableFonts || [] // Object with all available fonts (optional). Key is font name in lower case, value is link: {"arial": "/font1.ttf"} + self.onReadyEvent = options.onReady // Function called when SubtitlesOctopus is ready (optional) + if (supportsWebAssembly) { + self.workerUrl = options.workerUrl || 'subtitles-octopus-worker.js' // Link to WebAssembly worker } else { - if (!self.canvas) { - self.workerError('Don\'t know where to render: you should give video or canvas in options.') - } + self.workerUrl = options.legacyWorkerUrl || 'subtitles-octopus-worker-legacy.js' // Link to legacy worker } - if (!(self.renderMode === 'offscreenCanvas')) { - self.ctx = self.canvas.getContext('2d') - } - self.bufferCanvas = document.createElement('canvas') - self.bufferCanvasCtx = self.bufferCanvas.getContext('2d') - self.bufferCanvas2 = document.createElement('canvas') - self.bufferCanvasCtx2 = self.bufferCanvas.getContext('2d') + self.subUrl = options.subUrl // Link to sub file (optional if subContent specified) + self.subContent = options.subContent || null // Sub content (optional if subUrl specified) + self.onErrorEvent = options.onError // Function called in case of critical error meaning sub wouldn't be shown and you should use alternative method (for instance it occurs if browser doesn't support web workers). + self.debug = options.debug || false // When debug enabled, some performance info printed in console. + self.lastRenderTime = 0 // (internal) Last time we got some frame from worker + self.pixelRatio = window.devicePixelRatio || 1 // (internal) Device pixel ratio (for high dpi devices) - // test for alpha bug, where e.g. WebKit can render a transparent pixel - // (with alpha == 0) as non-black which then leads to visual artifacts - self.bufferCanvas.width = 1 - self.bufferCanvas.height = 1 - self.bufferCanvas2.width = 1 - self.bufferCanvas2.height = 1 - const testBuf = new Uint8ClampedArray([0, 255, 0, 0]) - const testImage = new ImageData(testBuf, 1, 1) - self.bufferCanvasCtx.clearRect(0, 0, 1, 1) - self.bufferCanvasCtx2.clearRect(0, 0, 1, 1) - const prePut = self.bufferCanvasCtx2.getImageData(0, 0, 1, 1).data - self.bufferCanvasCtx.putImageData(testImage, 0, 0) - self.bufferCanvasCtx2.drawImage(self.bufferCanvas, 0, 0) - const postPut = self.bufferCanvasCtx2.getImageData(0, 0, 1, 1).data - self.hasAlphaBug = prePut[1] !== postPut[1] - if (self.hasAlphaBug) { - console.log('Detected a browser having issue with transparent pixels, applying workaround') - } - } + self.timeOffset = options.timeOffset || 0 // Time offset would be applied to currentTime from video (option) - self.setVideo = function (video) { - self.video = video - if (self.video) { - const timeupdate = function () { - self.setCurrentTime(video.currentTime + self.timeOffset) - } - self.video.addEventListener('timeupdate', timeupdate, false) - self.video.addEventListener('playing', function () { - self.setIsPaused(false, video.currentTime + self.timeOffset) - }, false) - self.video.addEventListener('pause', function () { - self.setIsPaused(true, video.currentTime + self.timeOffset) - }, false) - self.video.addEventListener('seeking', function () { - self.video.removeEventListener('timeupdate', timeupdate) - }, false) - self.video.addEventListener('seeked', function () { - self.video.addEventListener('timeupdate', timeupdate, false) - self.setCurrentTime(video.currentTime + self.timeOffset) - if (self.renderAhead > 0) { - _cleanPastRendered(video.currentTime + self.timeOffset, true) + self.renderedItems = [] // used to store items rendered ahead when renderAhead > 0 + self.renderAhead = self.renderAhead * 1024 * 1024 * 0.9 /* try to eat less than requested */ + self.oneshotState = { + eventStart: null, + eventOver: false, + iteration: 0, + renderRequested: false, + requestNextTimestamp: -1, + prevWidth: null, + prevHeight: null + } + + self.hasAlphaBug = false; + + (function () { + if (typeof ImageData.prototype.constructor === 'function') { + try { + // try actually calling ImageData, as on some browsers it's reported + // as existing but calling it errors out as "TypeError: Illegal constructor" + const test = new window.ImageData(new Uint8ClampedArray([0, 0, 0, 0]), 1, 1) + return + } catch (e) { + console.log('detected that ImageData is not constructable despite browser saying so') } - }, false) - self.video.addEventListener('ratechange', function () { - self.setRate(video.playbackRate) - }, false) - self.video.addEventListener('timeupdate', function () { - self.setCurrentTime(video.currentTime + self.timeOffset) - }, false) - self.video.addEventListener('waiting', function () { - self.setIsPaused(true, video.currentTime + self.timeOffset) - }, false) - - document.addEventListener('fullscreenchange', self.resizeWithTimeout, false) - document.addEventListener('mozfullscreenchange', self.resizeWithTimeout, false) - document.addEventListener('webkitfullscreenchange', self.resizeWithTimeout, false) - document.addEventListener('msfullscreenchange', self.resizeWithTimeout, false) - window.addEventListener('resize', self.resizeWithTimeout, false) - - // Support Element Resize Observer - if (typeof ResizeObserver !== 'undefined') { - self.ro = new ResizeObserver(self.resizeWithTimeout) - self.ro.observe(self.video) } - if (self.video.videoWidth > 0) { - self.resize() - } else { - self.video.addEventListener('loadedmetadata', function (e) { - e.target.removeEventListener(e.type, arguments.callee) - self.resize() - }, false) + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + + window.ImageData = function () { + let i = 0 + let data + if (arguments[0] instanceof Uint8ClampedArray) { + data = arguments[i++] + } + const width = arguments[i++] + const height = arguments[i] + + const imageData = ctx.createImageData(width, height) + if (data) imageData.data.set(data) + return imageData } - } - } + })() - self.getVideoPosition = function () { - const videoRatio = self.video.videoWidth / self.video.videoHeight - const width = self.video.offsetWidth; const height = self.video.offsetHeight - const elementRatio = width / height - let realWidth = width; let realHeight = height - if (elementRatio > videoRatio) realWidth = Math.floor(height * videoRatio) - else realHeight = Math.floor(width / videoRatio) - - const x = (width - realWidth) / 2 - const y = (height - realHeight) / 2 - - return { - width: realWidth, - height: realHeight, - x: x, - y: y - } - } - - self.setSubUrl = function (subUrl) { - self.subUrl = subUrl - } - - function _cleanPastRendered (currentTime, seekClean) { - let retainedItems = [] - for (const item in self.renderedItems) { - if (item.emptyFinish < 0 || item.emptyFinish >= currentTime) { - // item is not yet finished, retain it - retainedItems.push(item) + self.workerError = function (error) { + console.error('Worker error: ', error) + if (self.onErrorEvent) { + self.onErrorEvent(error) + } + if (!self.debug) { + self.dispose() + throw new Error('Worker error: ' + error) } } - if (seekClean && retainedItems.length > 0) { - // items are ordered by event start time when we push to self.renderedItems, - // so first item is the earliest - if (currentTime < retainedItems[0].eventStart) { - if (retainedItems[0].eventStart - currentTime > 60) { - console.info('seeked back too far, cleaning prerender buffer') - retainedItems = [] + // Not tested for repeated usage yet + self.init = function () { + if (!window.Worker) { + self.workerError('worker not supported') + return + } + // Worker + if (!self.worker) { + self.worker = new Worker(self.workerUrl) + self.worker.onmessage = self.onWorkerMessage + self.worker.onerror = self.workerError + } + self.workerActive = false + self.createCanvas() + self.setVideo(options.video) + self.setSubUrl(options.subUrl) + self.worker.postMessage({ + target: 'worker-init', + width: self.canvas.width, + height: self.canvas.height, + URL: document.URL, + currentScript: self.workerUrl, + preMain: true, + renderMode: self.renderMode, + subUrl: self.subUrl, + subContent: self.subContent, + fonts: self.fonts, + availableFonts: self.availableFonts, + debug: self.debug, + targetFps: self.targetFps, + libassMemoryLimit: self.libassMemoryLimit, + libassGlyphLimit: self.libassGlyphLimit, + renderOnDemand: self.renderAhead > 0, + dropAllAnimations: self.dropAllAnimations + }) + if (self.renderMode === 'offscreenCanvas') { + self.pushOffscreenCanvas() + self.initDone = true + } + } + self.pushOffscreenCanvas = function () { + const canvasControl = self.canvas.transferControlToOffscreen() + self.worker.postMessage({ + target: 'offscreenCanvas', + canvas: canvasControl + }, [canvasControl]) + } + + self.createCanvas = function () { + if (self.video) { + self.isOurCanvas = true + self.canvas = document.createElement('canvas') + self.canvas.className = 'libassjs-canvas' + self.canvas.style.display = 'none' + self.canvas.id = 'subtitleCanvas' + + self.canvasParent = document.createElement('div') + self.canvasParent.className = 'libassjs-canvas-parent' + self.canvasParent.appendChild(self.canvas) + + if (self.video.nextSibling) { + self.video.parentNode.insertBefore(self.canvasParent, self.video.nextSibling) } else { - console.info('seeked backwards, need to free up some buffer') - let size = 0; const limit = self.renderAhead * 0.3 /* try to take no more than 1/3 of buffer */ - const retain = [] - for (const item in retainedItems) { - size += item.size - if (size >= limit) break - retain.push(item) - } - retainedItems = retain + self.video.parentNode.appendChild(self.canvasParent) } - } - } - - const removed = retainedItems.length < self.renderedItems - self.renderedItems = retainedItems - return removed - } - - function tryRequestOneshot (currentTime, renderNow) { - if (!self.renderAhead || self.renderAhead <= 0) return - if (self.oneshotState.renderRequested && !renderNow) return - - if (typeof currentTime === 'undefined') { - if (!self.video) return - currentTime = self.video.currentTime + self.timeOffset - } - - let size = 0 - for (let i = 0, len = self.renderedItems.length; i < len; i++) { - const item = self.renderedItems[i] - if (item.emptyFinish < 0) { - console.info('oneshot already reached end-of-events') - return - } - if (currentTime >= item.eventStart && currentTime < item.emptyFinish) { - // an event for requested time already exists - console.debug('not requesting a render for ' + currentTime + - ' as event already covering it exists (start=' + - item.eventStart + ', empty=' + item.emptyFinish + ')') - return - } - size += item.size - } - - if (size <= self.renderAhead) { - lastRendered = currentTime - (renderNow ? 0 : 0.001) - if (!self.oneshotState.renderRequested) { - self.oneshotState.renderRequested = true - self.worker.postMessage({ - target: 'oneshot-render', - lastRendered: lastRendered, - renderNow: renderNow, - iteration: self.oneshotState.iteration - }) } else { - if (self.workerActive) { - console.info('worker busy, requesting to seek') + if (!self.canvas) { + self.workerError('Don\'t know where to render: you should give video or canvas in options.') } - self.oneshotState.requestNextTimestamp = lastRendered + } + if (!(self.renderMode === 'offscreenCanvas')) { + self.ctx = self.canvas.getContext('2d') + } + self.bufferCanvas = document.createElement('canvas') + self.bufferCanvasCtx = self.bufferCanvas.getContext('2d') + self.bufferCanvas2 = document.createElement('canvas') + self.bufferCanvasCtx2 = self.bufferCanvas.getContext('2d') + + // test for alpha bug, where e.g. WebKit can render a transparent pixel + // (with alpha == 0) as non-black which then leads to visual artifacts + self.bufferCanvas.width = 1 + self.bufferCanvas.height = 1 + self.bufferCanvas2.width = 1 + self.bufferCanvas2.height = 1 + const testBuf = new Uint8ClampedArray([0, 255, 0, 0]) + const testImage = new ImageData(testBuf, 1, 1) + self.bufferCanvasCtx.clearRect(0, 0, 1, 1) + self.bufferCanvasCtx2.clearRect(0, 0, 1, 1) + const prePut = self.bufferCanvasCtx2.getImageData(0, 0, 1, 1).data + self.bufferCanvasCtx.putImageData(testImage, 0, 0) + self.bufferCanvasCtx2.drawImage(self.bufferCanvas, 0, 0) + const postPut = self.bufferCanvasCtx2.getImageData(0, 0, 1, 1).data + self.hasAlphaBug = prePut[1] !== postPut[1] + if (self.hasAlphaBug) { + console.log('Detected a browser having issue with transparent pixels, applying workaround') } } - } - function _renderSubtitleEvent (event, currentTime) { - const eventOver = event.eventFinish < currentTime - if (self.oneshotState.eventStart === event.eventStart && self.oneshotState.eventOver === eventOver) return - self.oneshotState.eventStart = event.eventStart - self.oneshotState.eventOver = eventOver + self.setVideo = function (video) { + self.video = video + if (self.video) { + const timeupdate = function () { + self.setCurrentTime(video.currentTime + self.timeOffset) + } + self.video.addEventListener('timeupdate', timeupdate, false) + self.video.addEventListener('playing', function () { + self.setIsPaused(false, video.currentTime + self.timeOffset) + }, false) + self.video.addEventListener('pause', function () { + self.setIsPaused(true, video.currentTime + self.timeOffset) + }, false) + self.video.addEventListener('seeking', function () { + self.video.removeEventListener('timeupdate', timeupdate) + }, false) + self.video.addEventListener('seeked', function () { + self.video.addEventListener('timeupdate', timeupdate, false) + self.setCurrentTime(video.currentTime + self.timeOffset) + if (self.renderAhead > 0) { + _cleanPastRendered(video.currentTime + self.timeOffset, true) + } + }, false) + self.video.addEventListener('ratechange', function () { + self.setRate(video.playbackRate) + }, false) + self.video.addEventListener('timeupdate', function () { + self.setCurrentTime(video.currentTime + self.timeOffset) + }, false) + self.video.addEventListener('waiting', function () { + self.setIsPaused(true, video.currentTime + self.timeOffset) + }, false) - const beforeDrawTime = performance.now() - if (event.viewport.width !== self.canvas.width || event.viewport.height !== self.canvas.height) { - self.canvas.width = event.viewport.width - self.canvas.height = event.viewport.height + document.addEventListener('fullscreenchange', self.resizeWithTimeout, false) + document.addEventListener('mozfullscreenchange', self.resizeWithTimeout, false) + document.addEventListener('webkitfullscreenchange', self.resizeWithTimeout, false) + document.addEventListener('msfullscreenchange', self.resizeWithTimeout, false) + window.addEventListener('resize', self.resizeWithTimeout, false) + + // Support Element Resize Observer + if (typeof ResizeObserver !== 'undefined') { + self.ro = new ResizeObserver(self.resizeWithTimeout) + self.ro.observe(self.video) + } + + if (self.video.videoWidth > 0) { + self.resize() + } else { + self.video.addEventListener('loadedmetadata', function (e) { + e.target.removeEventListener(e.type, arguments.callee) + self.resize() + }, false) + } + } } - self.ctx.clearRect(0, 0, self.canvas.width, self.canvas.height) - if (!eventOver) { - for (let i = 0; i < event.items.length; i++) { - const image = event.items[i] + + self.getVideoPosition = function () { + const videoRatio = self.video.videoWidth / self.video.videoHeight + const width = self.video.offsetWidth; const height = self.video.offsetHeight + const elementRatio = width / height + let realWidth = width; let realHeight = height + if (elementRatio > videoRatio) { realWidth = Math.floor(height * videoRatio) } else { realHeight = Math.floor(width / videoRatio) } + + const x = (width - realWidth) / 2 + const y = (height - realHeight) / 2 + + return { + width: realWidth, + height: realHeight, + x: x, + y: y + } + } + + self.setSubUrl = function (subUrl) { + self.subUrl = subUrl + } + + function _cleanPastRendered (currentTime, seekClean) { + let retainedItems = [] + for (const item in self.renderedItems) { + if (item.emptyFinish < 0 || item.emptyFinish >= currentTime) { + // item is not yet finished, retain it + retainedItems.push(item) + } + } + + if (seekClean && retainedItems.length > 0) { + // items are ordered by event start time when we push to self.renderedItems, + // so first item is the earliest + if (currentTime < retainedItems[0].eventStart) { + if (retainedItems[0].eventStart - currentTime > 60) { + console.info('seeked back too far, cleaning prerender buffer') + retainedItems = [] + } else { + console.info('seeked backwards, need to free up some buffer') + let size = 0; const limit = self.renderAhead * 0.3 /* try to take no more than 1/3 of buffer */ + const retain = [] + for (const item in retainedItems) { + size += item.size + if (size >= limit) { break } + retain.push(item) + } + retainedItems = retain + } + } + } + + const removed = retainedItems.length < self.renderedItems + self.renderedItems = retainedItems + return removed + } + + function tryRequestOneshot (currentTime, renderNow) { + if (!self.renderAhead || self.renderAhead <= 0) { return } + if (self.oneshotState.renderRequested && !renderNow) { return } + + if (typeof currentTime === 'undefined') { + if (!self.video) { return } + currentTime = self.video.currentTime + self.timeOffset + } + + let size = 0 + for (let i = 0, len = self.renderedItems.length; i < len; i++) { + const item = self.renderedItems[i] + if (item.emptyFinish < 0) { + console.info('oneshot already reached end-of-events') + return + } + if (currentTime >= item.eventStart && currentTime < item.emptyFinish) { + // an event for requested time already exists + console.debug('not requesting a render for ' + currentTime + + ' as event already covering it exists (start=' + + item.eventStart + ', empty=' + item.emptyFinish + ')') + return + } + size += item.size + } + + if (size <= self.renderAhead) { + lastRendered = currentTime - (renderNow ? 0 : 0.001) + if (!self.oneshotState.renderRequested) { + self.oneshotState.renderRequested = true + self.worker.postMessage({ + target: 'oneshot-render', + lastRendered: lastRendered, + renderNow: renderNow, + iteration: self.oneshotState.iteration + }) + } else { + if (self.workerActive) { + console.info('worker busy, requesting to seek') + } + self.oneshotState.requestNextTimestamp = lastRendered + } + } + } + + function _renderSubtitleEvent (event, currentTime) { + const eventOver = event.eventFinish < currentTime + if (self.oneshotState.eventStart === event.eventStart && self.oneshotState.eventOver === eventOver) { return } + self.oneshotState.eventStart = event.eventStart + self.oneshotState.eventOver = eventOver + + const beforeDrawTime = performance.now() + if (event.viewport.width !== self.canvas.width || event.viewport.height !== self.canvas.height) { + self.canvas.width = event.viewport.width + self.canvas.height = event.viewport.height + } + self.ctx.clearRect(0, 0, self.canvas.width, self.canvas.height) + if (!eventOver) { + for (let i = 0; i < event.items.length; i++) { + const image = event.items[i] + self.bufferCanvas.width = image.w + self.bufferCanvas.height = image.h + self.bufferCanvasCtx.putImageData(image.image, 0, 0) + self.ctx.drawImage(self.bufferCanvas, image.x, image.y) + } + } + if (self.debug) { + const drawTime = Math.round(performance.now() - beforeDrawTime) + console.log('render: ' + Math.round(event.spentTime - event.blendTime) + ' ms, blend: ' + Math.round(event.blendTime) + ' ms, draw: ' + drawTime + ' ms') + } + } + + function oneshotRender () { + window.requestAnimationFrame(oneshotRender) + if (!self.video) { return } + + const currentTime = self.video.currentTime + self.timeOffset + let finishTime = -1; let eventShown = false; let animated = false + for (let i = 0, len = self.renderedItems.length; i < len; i++) { + const item = self.renderedItems[i] + if (!eventShown && item.eventStart <= currentTime && (item.emptyFinish < 0 || item.emptyFinish > currentTime)) { + _renderSubtitleEvent(item, currentTime) + eventShown = true + finishTime = item.emptyFinish + } else if (finishTime >= 0) { + // we've already found a known event, now find + // the farthest point of consequent events + // NOTE: self.renderedItems may have gaps due to seeking + if (item.eventStart - finishTime < 0.01) { + finishTime = item.emptyFinish + animated = item.animated + } else { + break + } + } + } + + if (!eventShown) { + if (Math.abs(self.oneshotState.requestNextTimestamp - currentTime) > 0.01) { + _cleanPastRendered(currentTime) + tryRequestOneshot(currentTime, true) + } + } else if (_cleanPastRendered(currentTime) && finishTime >= 0) { + tryRequestOneshot(finishTime, animated) + } + } + + self.resetRenderAheadCache = function (isResizing) { + if (self.renderAhead > 0) { + const newCache = [] + if (isResizing && self.oneshotState.prevHeight && self.oneshotState.prevWidth) { + if (self.oneshotState.prevHeight === self.canvas.height && + self.oneshotState.prevWidth === self.canvas.width) { return } + let timeLimit = 10; let sizeLimit = self.renderAhead * 0.3 + if (self.canvas.height >= self.oneshotState.prevHeight * (1.0 - self.resizeVariation) && + self.canvas.height <= self.oneshotState.prevHeight * (1.0 + self.resizeVariation) && + self.canvas.width >= self.oneshotState.prevWidth * (1.0 - self.resizeVariation) && + self.canvas.width <= self.oneshotState.prevWidth * (1.0 + self.resizeVariation)) { + console.debug('viewport changes are small, leaving more of prerendered buffer') + timeLimit = 30 + sizeLimit = self.renderAhead * 0.5 + } + const stopTime = self.video.currentTime + self.timeOffset + timeLimit + let size = 0 + for (let i = 0; i < self.renderedItems.length; i++) { + const item = self.renderedItems[i] + if (item.emptyFinish < 0 || item.emptyFinish >= stopTime) { break } + size += item.size + if (size >= sizeLimit) { break } + newCache.push(item) + } + } + + console.info('resetting prerender cache') + self.renderedItems = newCache + self.oneshotState.eventStart = null + self.oneshotState.iteration++ + self.oneshotState.renderRequested = false + self.oneshotState.prevHeight = self.canvas.height + self.oneshotState.prevWidth = self.canvas.width + + window.requestAnimationFrame(oneshotRender) + tryRequestOneshot(undefined, true) + } + } + + self.renderFrameData = null + function renderFrames () { + const data = self.renderFramesData + const beforeDrawTime = performance.now() + self.ctx.clearRect(0, 0, self.canvas.width, self.canvas.height) + for (let i = 0; i < data.canvases.length; i++) { + const image = data.canvases[i] self.bufferCanvas.width = image.w self.bufferCanvas.height = image.h - self.bufferCanvasCtx.putImageData(image.image, 0, 0) + const imageBuffer = new Uint8ClampedArray(image.buffer) + if (self.hasAlphaBug) { + for (let j = 3; j < imageBuffer.length; j = j + 4) { + imageBuffer[j] = (imageBuffer[j] >= 1) ? imageBuffer[j] : 1 + } + } + const imageData = new ImageData(imageBuffer, image.w, image.h) + self.bufferCanvasCtx.putImageData(imageData, 0, 0) self.ctx.drawImage(self.bufferCanvas, image.x, image.y) } - } - if (self.debug) { - const drawTime = Math.round(performance.now() - beforeDrawTime) - console.log('render: ' + Math.round(event.spentTime - event.blendTime) + ' ms, blend: ' + Math.round(event.blendTime) + ' ms, draw: ' + drawTime + ' ms') - } - } - - function oneshotRender () { - window.requestAnimationFrame(oneshotRender) - if (!self.video) return - - const currentTime = self.video.currentTime + self.timeOffset - let finishTime = -1; let eventShown = false; let animated = false - for (let i = 0, len = self.renderedItems.length; i < len; i++) { - const item = self.renderedItems[i] - if (!eventShown && item.eventStart <= currentTime && (item.emptyFinish < 0 || item.emptyFinish > currentTime)) { - _renderSubtitleEvent(item, currentTime) - eventShown = true - finishTime = item.emptyFinish - } else if (finishTime >= 0) { - // we've already found a known event, now find - // the farthest point of consequent events - // NOTE: self.renderedItems may have gaps due to seeking - if (item.eventStart - finishTime < 0.01) { - finishTime = item.emptyFinish - animated = item.animated + if (self.debug) { + const drawTime = Math.round(performance.now() - beforeDrawTime) + const blendTime = data.blendTime + if (typeof blendTime !== 'undefined') { + console.log('render: ' + Math.round(data.spentTime - blendTime) + ' ms, blend: ' + Math.round(blendTime) + ' ms, draw: ' + drawTime + ' ms; TOTAL=' + Math.round(data.spentTime + drawTime) + ' ms') } else { + console.log(Math.round(data.spentTime) + ' ms (+ ' + drawTime + ' ms draw)') + } + self.renderStart = performance.now() + } + } + + /** + * Lossy Render Mode + * + */ + function renderFastFrames () { + const data = self.renderFramesData + const beforeDrawTime = performance.now() + self.ctx.clearRect(0, 0, self.canvas.width, self.canvas.height) + for (let i = 0; i < data.bitmaps.length; i++) { + const image = data.bitmaps[i] + self.ctx.drawImage(image.bitmap, image.x, image.y) + } + if (self.debug) { + const drawTime = Math.round(performance.now() - beforeDrawTime) + console.log(data.bitmaps.length + ' bitmaps, libass: ' + Math.round(data.libassTime) + 'ms, decode: ' + Math.round(data.decodeTime) + 'ms, draw: ' + drawTime + 'ms') + self.renderStart = performance.now() + } + } + + self.workerActive = false + self.frameId = 0 + self.onWorkerMessage = function (event) { + // dump('\nclient got ' + JSON.stringify(event.data).substr(0, 150) + '\n'); + if (!self.workerActive) { + self.workerActive = true + if (self.onReadyEvent) { + self.onReadyEvent() + } + } + const data = event.data + switch (data.target) { + case 'stdout': { + console.log(data.content) break } - } - } - - if (!eventShown) { - if (Math.abs(self.oneshotState.requestNextTimestamp - currentTime) > 0.01) { - _cleanPastRendered(currentTime) - tryRequestOneshot(currentTime, true) - } - } else if (_cleanPastRendered(currentTime) && finishTime >= 0) { - tryRequestOneshot(finishTime, animated) - } - } - - self.resetRenderAheadCache = function (isResizing) { - if (self.renderAhead > 0) { - const newCache = [] - if (isResizing && self.oneshotState.prevHeight && self.oneshotState.prevWidth) { - if (self.oneshotState.prevHeight === self.canvas.height && - self.oneshotState.prevWidth === self.canvas.width) return - let timeLimit = 10; let sizeLimit = self.renderAhead * 0.3 - if (self.canvas.height >= self.oneshotState.prevHeight * (1.0 - self.resizeVariation) && - self.canvas.height <= self.oneshotState.prevHeight * (1.0 + self.resizeVariation) && - self.canvas.width >= self.oneshotState.prevWidth * (1.0 - self.resizeVariation) && - self.canvas.width <= self.oneshotState.prevWidth * (1.0 + self.resizeVariation)) { - console.debug('viewport changes are small, leaving more of prerendered buffer') - timeLimit = 30 - sizeLimit = self.renderAhead * 0.5 + case 'console-log': { + console.log.apply(console, JSON.parse(data.content)) + break } - const stopTime = self.video.currentTime + self.timeOffset + timeLimit - let size = 0 - for (let i = 0; i < self.renderedItems.length; i++) { - const item = self.renderedItems[i] - if (item.emptyFinish < 0 || item.emptyFinish >= stopTime) break - size += item.size - if (size >= sizeLimit) break - newCache.push(item) + case 'console-debug': { + console.debug.apply(console, JSON.parse(data.content)) + break } - } - - console.info('resetting prerender cache') - self.renderedItems = newCache - self.oneshotState.eventStart = null - self.oneshotState.iteration++ - self.oneshotState.renderRequested = false - self.oneshotState.prevHeight = self.canvas.height - self.oneshotState.prevWidth = self.canvas.width - - window.requestAnimationFrame(oneshotRender) - tryRequestOneshot(undefined, true) - } - } - - self.renderFrameData = null - function renderFrames () { - const data = self.renderFramesData - const beforeDrawTime = performance.now() - self.ctx.clearRect(0, 0, self.canvas.width, self.canvas.height) - for (let i = 0; i < data.canvases.length; i++) { - const image = data.canvases[i] - self.bufferCanvas.width = image.w - self.bufferCanvas.height = image.h - const imageBuffer = new Uint8ClampedArray(image.buffer) - if (self.hasAlphaBug) { - for (let j = 3; j < imageBuffer.length; j = j + 4) { - imageBuffer[j] = (imageBuffer[j] >= 1) ? imageBuffer[j] : 1 + case 'console-info': { + console.info.apply(console, JSON.parse(data.content)) + break } - } - const imageData = new ImageData(imageBuffer, image.w, image.h) - self.bufferCanvasCtx.putImageData(imageData, 0, 0) - self.ctx.drawImage(self.bufferCanvas, image.x, image.y) - } - if (self.debug) { - const drawTime = Math.round(performance.now() - beforeDrawTime) - const blendTime = data.blendTime - if (typeof blendTime !== 'undefined') { - console.log('render: ' + Math.round(data.spentTime - blendTime) + ' ms, blend: ' + Math.round(blendTime) + ' ms, draw: ' + drawTime + ' ms; TOTAL=' + Math.round(data.spentTime + drawTime) + ' ms') - } else { - console.log(Math.round(data.spentTime) + ' ms (+ ' + drawTime + ' ms draw)') - } - self.renderStart = performance.now() - } - } + case 'console-warn': { + console.warn.apply(console, JSON.parse(data.content)) + break + } + case 'console-error': { + console.error.apply(console, JSON.parse(data.content)) + break + } + case 'stderr': { + console.error(data.content) + break + } + case 'window': { + window[data.method]() + break + } + case 'ready': { + break + } + case 'canvas': { + switch (data.op) { + case 'getContext': { + if (!(self.renderMode === 'offscreenCanvas')) { self.ctx = self.canvas.getContext(data.type, data.attributes) } + break + } + case 'resize': { + self.resize(data.width, data.height) + break + } + case 'renderCanvas': { + if (self.lastRenderTime < data.time) { + self.lastRenderTime = data.time + self.renderFramesData = data + window.requestAnimationFrame(renderFrames) + } + break + } + case 'renderFastCanvas': { + if (self.lastRenderTime < data.time) { + self.lastRenderTime = data.time + self.renderFramesData = data + window.requestAnimationFrame(renderFastFrames) + } + break + } + case 'setObjectProperty': { + self.canvas[data.object][data.property] = data.value + break + } + case 'oneshot-result': { + if (data.iteration !== self.oneshotState.iteration) { + console.debug('received stale prerender, ignoring') + return + } - /** - * Lossy Render Mode - * - */ - function renderFastFrames () { - const data = self.renderFramesData - const beforeDrawTime = performance.now() - self.ctx.clearRect(0, 0, self.canvas.width, self.canvas.height) - for (let i = 0; i < data.bitmaps.length; i++) { - const image = data.bitmaps[i] - self.ctx.drawImage(image.bitmap, image.x, image.y) - } - if (self.debug) { - const drawTime = Math.round(performance.now() - beforeDrawTime) - console.log(data.bitmaps.length + ' bitmaps, libass: ' + Math.round(data.libassTime) + 'ms, decode: ' + Math.round(data.decodeTime) + 'ms, draw: ' + drawTime + 'ms') - self.renderStart = performance.now() - } - } + if (self.debug) { + console.info('oneshot received (start=' + + data.eventStart + ', empty=' + data.emptyFinish + + '), render: ' + Math.round(data.spentTime) + ' ms') + } + self.oneshotState.renderRequested = false + if (Math.abs(data.lastRenderedTime - self.oneshotState.requestNextTimestamp) < 0.01) { + self.oneshotState.requestNextTimestamp = -1 + } + if (data.eventStart - data.lastRenderedTime > 0.01) { + // generate bogus empty element, so all timeline is covered anyway + self.renderedItems.push({ + eventStart: data.lastRenderedTime, + eventFinish: data.lastRenderedTime - 0.001, + emptyFinish: data.eventStart, + viewport: data.viewport, + spentTime: 0, + blendTime: 0, + items: [], + animated: false, + size: 0 + }) + } - self.workerActive = false - self.frameId = 0 - self.onWorkerMessage = function (event) { - // dump('\nclient got ' + JSON.stringify(event.data).substr(0, 150) + '\n'); - if (!self.workerActive) { - self.workerActive = true - if (self.onReadyEvent) { - self.onReadyEvent() - } - } - const data = event.data - switch (data.target) { - case 'stdout': { - console.log(data.content) - break - } - case 'console-log': { - console.log.apply(console, JSON.parse(data.content)) - break - } - case 'console-debug': { - console.debug.apply(console, JSON.parse(data.content)) - break - } - case 'console-info': { - console.info.apply(console, JSON.parse(data.content)) - break - } - case 'console-warn': { - console.warn.apply(console, JSON.parse(data.content)) - break - } - case 'console-error': { - console.error.apply(console, JSON.parse(data.content)) - break - } - case 'stderr': { - console.error(data.content) - break - } - case 'window': { - window[data.method]() - break - } - case 'ready': { - break - } - case 'canvas': { - switch (data.op) { - case 'getContext': { - if (!(self.renderMode === 'offscreenCanvas')) self.ctx = self.canvas.getContext(data.type, data.attributes) - break - } - case 'resize': { - self.resize(data.width, data.height) - break - } - case 'renderCanvas': { - if (self.lastRenderTime < data.time) { - self.lastRenderTime = data.time - self.renderFramesData = data - window.requestAnimationFrame(renderFrames) - } - break - } - case 'renderFastCanvas': { - if (self.lastRenderTime < data.time) { - self.lastRenderTime = data.time - self.renderFramesData = data - window.requestAnimationFrame(renderFastFrames) - } - break - } - case 'setObjectProperty': { - self.canvas[data.object][data.property] = data.value - break - } - case 'oneshot-result': { - if (data.iteration !== self.oneshotState.iteration) { - console.debug('received stale prerender, ignoring') - return - } + const items = [] + let size = 0 + for (let i = 0, len = data.canvases.length; i < len; i++) { + const item = data.canvases[i] + items.push({ + w: item.w, + h: item.h, + x: item.x, + y: item.y, + image: new ImageData(new Uint8ClampedArray(item.buffer), item.w, item.h) + }) + size += item.buffer.byteLength + } - if (self.debug) { - console.info('oneshot received (start=' + - data.eventStart + ', empty=' + data.emptyFinish + - '), render: ' + Math.round(data.spentTime) + ' ms') - } - self.oneshotState.renderRequested = false - if (Math.abs(data.lastRenderedTime - self.oneshotState.requestNextTimestamp) < 0.01) { - self.oneshotState.requestNextTimestamp = -1 - } - if (data.eventStart - data.lastRenderedTime > 0.01) { - // generate bogus empty element, so all timeline is covered anyway + let eventSplitted = false + if ((data.emptyFinish > 0 && data.emptyFinish - data.eventStart < 1.0 / self.targetFps) || data.animated) { + const newFinish = data.eventStart + 1.0 / self.targetFps + data.emptyFinish = newFinish + data.eventFinish = newFinish + eventSplitted = true + } self.renderedItems.push({ - eventStart: data.lastRenderedTime, - eventFinish: data.lastRenderedTime - 0.001, - emptyFinish: data.eventStart, + eventStart: data.eventStart, + eventFinish: data.eventFinish, + emptyFinish: data.emptyFinish, + spentTime: data.spentTime, + blendTime: data.blendTime, viewport: data.viewport, - spentTime: 0, - blendTime: 0, - items: [], - animated: false, - size: 0 + items: items, + animated: data.animated, + size: size }) - } - const items = [] - let size = 0 - for (let i = 0, len = data.canvases.length; i < len; i++) { - const item = data.canvases[i] - items.push({ - w: item.w, - h: item.h, - x: item.x, - y: item.y, - image: new ImageData(new Uint8ClampedArray(item.buffer), item.w, item.h) + self.renderedItems.sort(function (a, b) { + return a.eventStart - b.eventStart }) - size += item.buffer.byteLength - } - let eventSplitted = false - if ((data.emptyFinish > 0 && data.emptyFinish - data.eventStart < 1.0 / self.targetFps) || data.animated) { - const newFinish = data.eventStart + 1.0 / self.targetFps - data.emptyFinish = newFinish - data.eventFinish = newFinish - eventSplitted = true + if (self.oneshotState.requestNextTimestamp >= 0) { + // requesting an out of order event render + tryRequestOneshot(self.oneshotState.requestNextTimestamp, true) + } else if (data.eventStart < 0) { + console.info('oneshot received "end of frames" event') + } else if (data.emptyFinish >= 0) { + // there's some more event to render, try requesting next event + tryRequestOneshot(data.emptyFinish, eventSplitted) + } else { + console.info('there are no more events to prerender') + } + break } - self.renderedItems.push({ - eventStart: data.eventStart, - eventFinish: data.eventFinish, - emptyFinish: data.emptyFinish, - spentTime: data.spentTime, - blendTime: data.blendTime, - viewport: data.viewport, - items: items, - animated: data.animated, - size: size - }) - - self.renderedItems.sort(function (a, b) { - return a.eventStart - b.eventStart - }) - - if (self.oneshotState.requestNextTimestamp >= 0) { - // requesting an out of order event render - tryRequestOneshot(self.oneshotState.requestNextTimestamp, true) - } else if (data.eventStart < 0) { - console.info('oneshot received "end of frames" event') - } else if (data.emptyFinish >= 0) { - // there's some more event to render, try requesting next event - tryRequestOneshot(data.emptyFinish, eventSplitted) - } else { - console.info('there are no more events to prerender') - } - break + default: + console.error('eh?') } - default: - console.error('eh?') + break } - break + case 'tick': { + self.frameId = data.id + self.worker.postMessage({ + target: 'tock', + id: self.frameId + }) + break + } + case 'custom': { + if (self.onCustomMessage) { + self.onCustomMessage(event) + } else { + console.error('Custom message received but client onCustomMessage not implemented.') + } + break + } + case 'setimmediate': { + self.worker.postMessage({ + target: 'setimmediate' + }) + break + } + case 'get-events': { + console.log(data.target) + console.log(data.events) + break + } + case 'get-styles': { + console.log(data.target) + console.log(data.styles) + break + } + default: + throw data.target } - case 'tick': { - self.frameId = data.id - self.worker.postMessage({ - target: 'tock', - id: self.frameId - }) - break - } - case 'custom': { - if (self.onCustomMessage) { - self.onCustomMessage(event) + } + + function _computeCanvasSize (width, height) { + if (self.prescaleTradeoff === null) { + if (height > self.hardHeightLimit) { + width = width * self.hardHeightLimit / height + height = self.hardHeightLimit + } + } else if (self.prescaleTradeoff > 1) { + if (height * self.prescaleTradeoff <= self.softHeightLimit) { + width *= self.prescaleTradeoff + height *= self.prescaleTradeoff + } else if (height < self.softHeightLimit) { + width = width * self.softHeightLimit / height + height = self.softHeightLimit + } else if (height >= self.hardHeightLimit) { + width = width * self.hardHeightLimit / height + height = self.hardHeightLimit + } + } else if (height >= self.softHeightLimit) { + if (height * self.prescaleTradeoff <= self.softHeightLimit) { + width = width * self.softHeightLimit / height + height = self.softHeightLimit + } else if (height * self.prescaleTradeoff <= self.hardHeightLimit) { + width *= self.prescaleTradeoff + height *= self.prescaleTradeoff } else { - console.error('Custom message received but client onCustomMessage not implemented.') + width = width * self.hardHeightLimit / height + height = self.hardHeightLimit } - break } - case 'setimmediate': { - self.worker.postMessage({ - target: 'setimmediate' - }) - break - } - case 'get-events': { - console.log(data.target) - console.log(data.events) - break - } - case 'get-styles': { - console.log(data.target) - console.log(data.styles) - break - } - default: - throw data.target - } - } - function _computeCanvasSize (width, height) { - if (self.prescaleTradeoff === null) { - if (height > self.hardHeightLimit) { - width = width * self.hardHeightLimit / height - height = self.hardHeightLimit - } - } else if (self.prescaleTradeoff > 1) { - if (height * self.prescaleTradeoff <= self.softHeightLimit) { - width *= self.prescaleTradeoff - height *= self.prescaleTradeoff - } else if (height < self.softHeightLimit) { - width = width * self.softHeightLimit / height - height = self.softHeightLimit - } else if (height >= self.hardHeightLimit) { - width = width * self.hardHeightLimit / height - height = self.hardHeightLimit - } - } else if (height >= self.softHeightLimit) { - if (height * self.prescaleTradeoff <= self.softHeightLimit) { - width = width * self.softHeightLimit / height - height = self.softHeightLimit - } else if (height * self.prescaleTradeoff <= self.hardHeightLimit) { - width *= self.prescaleTradeoff - height *= self.prescaleTradeoff - } else { - width = width * self.hardHeightLimit / height - height = self.hardHeightLimit - } + return { width: width, height: height } } - return { width: width, height: height } - } - - self.resize = function (width, height, top, left) { - let videoSize = null - top = top || 0 - left = left || 0 - if ((!width || !height) && self.video) { - videoSize = self.getVideoPosition() - const newsize = _computeCanvasSize(videoSize.width * self.pixelRatio, videoSize.height * self.pixelRatio) - width = newsize.width - height = newsize.height - const offset = self.canvasParent.getBoundingClientRect().top - self.video.getBoundingClientRect().top - top = videoSize.y - offset - left = videoSize.x - } - if (!width || !height) { - if (!self.video) { - console.error('width or height is 0. You should specify width & height for resize.') + self.resize = function (width, height, top, left) { + let videoSize = null + top = top || 0 + left = left || 0 + if ((!width || !height) && self.video) { + videoSize = self.getVideoPosition() + const newsize = _computeCanvasSize(videoSize.width * self.pixelRatio, videoSize.height * self.pixelRatio) + width = newsize.width + height = newsize.height + const offset = self.canvasParent.getBoundingClientRect().top - self.video.getBoundingClientRect().top + top = videoSize.y - offset + left = videoSize.x } - return - } - - if ( - self.canvas.width !== width || - self.canvas.height !== height || - self.canvas.style.top !== top || - self.canvas.style.left !== left - ) { - if (videoSize != null) { - self.canvasParent.style.position = 'relative' - self.canvas.style.display = 'block' - self.canvas.style.position = 'absolute' - self.canvas.style.top = top + 'px' - self.canvas.style.left = left + 'px' - self.canvas.style.pointerEvents = 'none' - } - if (!(self.canvas.width === width && self.canvas.height === height)) { - // only re-paint if dimensions actually changed - if (self.renderMode === 'offscreenCanvas' && self.initDone) { - self.canvasParent.remove() - self.canvasParent = undefined - self.createCanvas() + if (!width || !height) { + if (!self.video) { + console.error('width or height is 0. You should specify width & height for resize.') } - function rePaint () { - if (self.canvasParent && self.renderMode === 'offscreenCanvas' && self.initDone) { + return + } + + if (self.canvas.width !== width || + self.canvas.height !== height || + self.canvas.style.top !== top || + self.canvas.style.left !== left) { + if (videoSize != null) { + self.canvasParent.style.position = 'relative' + self.canvas.style.display = 'block' + self.canvas.style.position = 'absolute' + self.canvas.style.top = top + 'px' + self.canvas.style.left = left + 'px' + self.canvas.style.pointerEvents = 'none' + } + if (!(self.canvas.width === width && self.canvas.height === height)) { + // only re-paint if dimensions actually changed + if (self.renderMode === 'offscreenCanvas' && self.initDone) { self.canvasParent.remove() + self.canvasParent = undefined self.createCanvas() } - self.canvas.width = width - self.canvas.height = height - if (videoSize != null) { - self.canvasParent.style.position = 'relative' - self.canvas.style.display = 'block' - self.canvas.style.position = 'absolute' - self.canvas.style.top = top + 'px' - self.canvas.style.left = left + 'px' - self.canvas.style.pointerEvents = 'none' + function rePaint () { + if (self.canvasParent && self.renderMode === 'offscreenCanvas' && self.initDone) { + self.canvasParent.remove() + self.createCanvas() + } + self.canvas.width = width + self.canvas.height = height + if (videoSize != null) { + self.canvasParent.style.position = 'relative' + self.canvas.style.display = 'block' + self.canvas.style.position = 'absolute' + self.canvas.style.top = top + 'px' + self.canvas.style.left = left + 'px' + self.canvas.style.pointerEvents = 'none' + } + if (self.renderMode === 'offscreenCanvas' && self.initDone) { + self.pushOffscreenCanvas() + } + self.worker.postMessage({ + target: 'canvas', + width: self.canvas.width, + height: self.canvas.height + }) + self.resetRenderAheadCache(true) } - if (self.renderMode === 'offscreenCanvas' && self.initDone) { - self.pushOffscreenCanvas() - } - self.worker.postMessage({ - target: 'canvas', - width: self.canvas.width, - height: self.canvas.height - }) - self.resetRenderAheadCache(true) - } - // dont spam re-paints like crazy when re-sizing with animations, but still update instantly without them - if (self.resizeTimeoutBuffer) { - clearTimeout(self.resizeTimeoutBuffer) - self.resizeTimeoutBuffer = setTimeout(() => { - self.resizeTimeoutBuffer = undefined + // dont spam re-paints like crazy when re-sizing with animations, but still update instantly without them + if (self.resizeTimeoutBuffer) { + clearTimeout(self.resizeTimeoutBuffer) + self.resizeTimeoutBuffer = setTimeout(() => { + self.resizeTimeoutBuffer = undefined + rePaint() + }, 50) + } else { rePaint() - }, 50) - } else { - rePaint() - self.resizeTimeoutBuffer = setTimeout(() => { - self.resizeTimeoutBuffer = undefined - }, 50) + self.resizeTimeoutBuffer = setTimeout(() => { + self.resizeTimeoutBuffer = undefined + }, 50) + } } } } - } - self.resizeWithTimeout = function () { - self.resize() - } - - self.runBenchmark = function () { - self.worker.postMessage({ - target: 'runBenchmark' - }) - } - - self.customMessage = function (data, options) { - options = options || {} - self.worker.postMessage({ - target: 'custom', - userData: data, - preMain: options.preMain - }) - } - - self.setCurrentTime = function (currentTime) { - self.worker.postMessage({ - target: 'video', - currentTime: currentTime - }) - } - - self.setTrackByUrl = function (url) { - self.worker.postMessage({ - target: 'set-track-by-url', - url: url - }) - self.resetRenderAheadCache(false) - } - - self.setTrack = function (content) { - self.worker.postMessage({ - target: 'set-track', - content: content - }) - self.resetRenderAheadCache(false) - } - - self.freeTrack = function (content) { - self.worker.postMessage({ - target: 'free-track' - }) - self.resetRenderAheadCache(false) - } - - self.render = self.setCurrentTime - - self.setIsPaused = function (isPaused, currentTime) { - self.worker.postMessage({ - target: 'video', - isPaused: isPaused, - currentTime: currentTime - }) - } - - self.setRate = function (rate) { - self.worker.postMessage({ - target: 'video', - rate: rate - }) - } - - self.dispose = function () { - self.worker.postMessage({ - target: 'destroy' - }) - - self.worker.terminate() - self.workerActive = false - // Remove the canvas element to remove residual subtitles rendered on player - if (self.video) { - self.video.parentNode.removeChild(self.canvasParent) + self.resizeWithTimeout = function () { + self.resize() } - } - self.createEvent = function (event) { - self.worker.postMessage({ - target: 'create-event', - event: event - }) - } + self.runBenchmark = function () { + self.worker.postMessage({ + target: 'runBenchmark' + }) + } - self.getEvents = function () { - self.worker.postMessage({ - target: 'get-events' - }) - } + self.customMessage = function (data, options) { + options = options || {} + self.worker.postMessage({ + target: 'custom', + userData: data, + preMain: options.preMain + }) + } - self.setEvent = function (event, index) { - self.worker.postMessage({ - target: 'set-event', - event: event, - index: index - }) - } + self.setCurrentTime = function (currentTime) { + self.worker.postMessage({ + target: 'video', + currentTime: currentTime + }) + } - self.removeEvent = function (index) { - self.worker.postMessage({ - target: 'remove-event', - index: index - }) - } + self.setTrackByUrl = function (url) { + self.worker.postMessage({ + target: 'set-track-by-url', + url: url + }) + self.resetRenderAheadCache(false) + } - self.createStyle = function (style) { - self.worker.postMessage({ - target: 'create-style', - style: style - }) - } + self.setTrack = function (content) { + self.worker.postMessage({ + target: 'set-track', + content: content + }) + self.resetRenderAheadCache(false) + } - self.getStyles = function () { - self.worker.postMessage({ - target: 'get-styles' - }) - } + self.freeTrack = function (content) { + self.worker.postMessage({ + target: 'free-track' + }) + self.resetRenderAheadCache(false) + } - self.setStyle = function (style, index) { - self.worker.postMessage({ - target: 'set-style', - style: style, - index: index - }) - } + self.render = self.setCurrentTime - self.removeStyle = function (index) { - self.worker.postMessage({ - target: 'remove-style', - index: index - }) - } + self.setIsPaused = function (isPaused, currentTime) { + self.worker.postMessage({ + target: 'video', + isPaused: isPaused, + currentTime: currentTime + }) + } - self.init() + self.setRate = function (rate) { + self.worker.postMessage({ + target: 'video', + rate: rate + }) + } + + self.dispose = function () { + self.worker.postMessage({ + target: 'destroy' + }) + + self.worker.terminate() + self.workerActive = false + // Remove the canvas element to remove residual subtitles rendered on player + if (self.video) { + self.video.parentNode.removeChild(self.canvasParent) + } + } + + self.createEvent = function (event) { + self.worker.postMessage({ + target: 'create-event', + event: event + }) + } + + self.getEvents = function () { + self.worker.postMessage({ + target: 'get-events' + }) + } + + self.setEvent = function (event, index) { + self.worker.postMessage({ + target: 'set-event', + event: event, + index: index + }) + } + + self.removeEvent = function (index) { + self.worker.postMessage({ + target: 'remove-event', + index: index + }) + } + + self.createStyle = function (style) { + self.worker.postMessage({ + target: 'create-style', + style: style + }) + } + + self.getStyles = function () { + self.worker.postMessage({ + target: 'get-styles' + }) + } + + self.setStyle = function (style, index) { + self.worker.postMessage({ + target: 'set-style', + style: style, + index: index + }) + } + + self.removeStyle = function (index) { + self.worker.postMessage({ + target: 'remove-style', + index: index + }) + } + + self.init() + } } if (typeof SubtitlesOctopusOnLoad === 'function') { diff --git a/app/js/torrentHandler.js b/app/js/torrentHandler.js deleted file mode 100644 index dc00f83..0000000 --- a/app/js/torrentHandler.js +++ /dev/null @@ -1,234 +0,0 @@ -const client = new WebTorrent({ maxConns: settings.torrent6, downloadLimit: settings.torrent7 * 1048576, uploadLimit: settings.torrent7 * 1572864 }) -window.onbeforeunload = () => { // cleanup shit before unloading to free RAM/drive - cleanupVideo() - cleanupTorrents() - if (playerData.fonts) playerData.fonts.forEach(file => URL.revokeObjectURL(file)) - if (dl.href) URL.revokeObjectURL(dl.href) -} - -const announceList = [ - ['wss://tracker.openwebtorrent.com'], - // ['wss://tracker.novage.com.ua/'] - // ['wss://tracker.btorrent.xyz'] // for now disabled cuz broken - // ['wss://tracker.webtorrent.io'], - // ['wss://tracker.fastcast.nz'], - // ['wss://video.blender.org:443/tracker/socket'], - // ['wss://tube.privacytools.io:443/tracker/socket'], - ['wss://tracker.sloppyta.co:443/announce'], - // ['wss://tracker.lab.vvc.niif.hu:443/announce'], - // ['wss://tracker.files.fm:7073/announce'], - // ['wss://open.tube:443/tracker/socket'], - ['wss://hub.bugout.link:443/announce'] - // ['wss://peertube.cpy.re:443/tracker/socket'], - // ['ws://tracker.sloppyta.co:80/announce'], - // ['ws://tracker.lab.vvc.niif.hu:80/announce'], - // ['ws://tracker.files.fm:7072/announce'], - // ['ws://tracker.btsync.cf:6969/announce'], - // ['ws://hub.bugout.link:80/announce'] -] -const videoExtensions = ['.3g2', '.3gp', '.asf', '.avi', '.dv', '.flv', '.gxf', '.m2ts', '.m4a', '.m4b', '.m4p', '.m4r', '.m4v', '.mkv', '.mov', '.mp4', '.mpd', '.mpeg', '.mpg', '.mxf', '.nut', '.ogm', '.ogv', '.swf', '.ts', '.vob', '.webm', '.wmv', '.wtv'] -const scope = '/app/' -const sw = navigator.serviceWorker.register('sw.js', { scope }).then(e => { - if (searchParams.get('file')) addTorrent(searchParams.get('file'), {}) // add a torrent if its in the link params -}).catch(e => { - if (String(e) === 'InvalidStateError: Failed to register a ServiceWorker: The document is in an invalid state.') { - location.reload() // weird workaround for a weird bug - } else { - throw e - } -}) - -// for debugging -function t (a) { - switch (a) { - case 1: - addTorrent('https://webtorrent.io/torrents/sintel.torrent', {}) - break - case 2: - addTorrent('https://webtorrent.io/torrents/tears-of-steel.torrent', {}) - break - case 3: - addTorrent('magnet:?xt=urn:btih:CE9156EB497762F8B7577B71C0647A4B0C3423E1&dn=Inception+%282010%29+720p+-+mkv+-+1.0GB+-+YIFY&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969%2Fannounce&tr=udp%3A%2F%2F9.rarbg.to%3A2920%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Ftracker.internetwarriors.net%3A1337%2Fannounce&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.pirateparty.gr%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.cyberia.is%3A6969%2Fannounce', {}) - break - } -} -// offline storage initial load -let offlineTorrents -async function loadOfflineStorage () { - offlineTorrents = JSON.parse(localStorage.getItem('offlineTorrents')) || {} - Object.values(offlineTorrents).forEach(torrentID => offlineDownload(new Blob([new Uint8Array(torrentID)]), true)) // adds all offline store torrents to the client -} - -// add torrent for offline download -function offlineDownload (torrentID, skipVerify) { - const torrent = client.add(torrentID, { - store: IdbChunkStore, - skipVerify: skipVerify - }) - torrent.on('metadata', async () => { - console.log(torrent) - if (!offlineTorrents[torrent.infoHash]) { - offlineTorrents[torrent.infoHash] = Array.from(torrent.torrentFile) - localStorage.setItem('offlineTorrents', JSON.stringify(offlineTorrents)) - } - const mediaInformation = await resolveFileMedia({ fileName: torrent.name, method: 'SearchName' }) - template = cardCreator(mediaInformation) - template.onclick = () => addTorrent(torrent, { media: mediaInformation.media, episode: mediaInformation.episode }) - document.querySelector('.downloads').appendChild(template) - }) -} - -loadOfflineStorage() - -// cleanup torrent and store -function cleanupTorrents () { - // creates an array of all non-offline store torrents and removes them - client.torrents.filter(torrent => !offlineTorrents[torrent.infoHash]).forEach(torrent => torrent.destroy({ destroyStore: true })) - document.querySelector('.playlist').innerHTML = '' -} - -// manually add trackers -WEBTORRENT_ANNOUNCE = announceList.map(arr => arr[0]).filter(url => url.indexOf('wss://') === 0) - -let videoFiles -async function playTorrent (torrent, opts) { - torrent.on('noPeers', () => { - if (torrent.progress !== 1) { - halfmoon.initStickyAlert({ - content: `Couldn't find peers for ${torrent.infoHash}! Try a torrent with more seeders.`, - title: 'Search Failed', - alertType: 'alert-danger', - fillType: '' - }) - } - }) - await sw - videoFiles = torrent.files.filter(file => videoExtensions.some(ext => file.name.endsWith(ext))) - if (videoFiles.length > 1) { - (async function () { - torrent.files.forEach(file => file.deselect()) - const frag = document.createDocumentFragment() - for (const file of videoFiles) { - const mediaInformation = await resolveFileMedia({ fileName: file.name, method: 'SearchName' }) - template = cardCreator(mediaInformation) - template.onclick = () => { - cleanupVideo() - buildVideo(torrent, { media: mediaInformation.media, episode: mediaInformation.parseObject.episode, file: file }) - } - frag.appendChild(template) - } - document.querySelector('.playlist').appendChild(frag) - }()) - } - if (videoFiles) { - buildVideo(torrent, opts) - } else { - halfmoon.initStickyAlert({ - content: `Couldn't find video file for ${torrent.infoHash}!`, - title: 'Search Failed', - alertType: 'alert-danger', - fillType: '' - }) - cleanupTorrents() - } -} - -function addTorrent (torrentID, opts) { - halfmoon.hideModal('tsearch') - document.location.hash = '#player' - cleanupVideo() - cleanupTorrents() - if (torrentID instanceof Object) { - playTorrent(torrentID, opts) - } else if (client.get(torrentID)) { - playTorrent(client.get(torrentID), opts) - } else { - client.add(torrentID, settings.torrent5 ? { store: IdbChunkStore } : {}, function (torrent) { - playTorrent(torrent, opts) - if (settings.torrent6) torrent.deselect(0, torrent.pieces.length - 1, false) - }) - } -} - -function serveFile (file, req) { - const res = { - status: 200, - headers: { - 'Content-Type': file._getMimeType(), - // Support range-requests - 'Accept-Ranges': 'bytes' - } - } - - // `rangeParser` returns an array of ranges, or an error code (number) if - // there was an error parsing the range. - let range = rangeParser(file.length, req.headers.get('range') || '') - - if (Array.isArray(range)) { - res.status = 206 // indicates that range-request was understood - - // no support for multi-range request, just use the first range - range = range[0] - - res.headers['Content-Range'] = `bytes ${range.start}-${range.end}/${file.length}` - res.headers['Content-Length'] = `${range.end - range.start + 1}` - } else { - range = null - res.headers['Content-Length'] = file.length - } - - res.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0' - res.headers.Expires = '0' - res.body = req.method === 'HEAD' ? '' : 'stream' - // parser is really a passthrough mkv stream now - const stream = file.createReadStream(range) - if ((file.name.endsWith('.mkv') || file.name.endsWith('.webm')) && !playerData.parsed) subStream(stream) - - return [res, req.method === 'GET' && (playerData.subtitleStream || stream)] -} - -// kind of a fetch event from service worker but for the main thread. -navigator.serviceWorker.addEventListener('message', evt => { - const request = new Request(evt.data.url, { - headers: evt.data.headers, - method: evt.data.method - }) - - const [port] = evt.ports - const respondWith = msg => port.postMessage(msg) - const pathname = request.url.split(evt.data.scope + 'webtorrent/')[1] - let [infoHash, ...filePath] = pathname.split('/') - filePath = decodeURI(filePath.join('/')) - - if (!infoHash || !filePath) return - - const torrent = client.get(infoHash) - const file = torrent.files.find(file => file.path === filePath) - - const [response, stream] = serveFile(file, request) - const asyncIterator = stream && stream[Symbol.asyncIterator]() - respondWith(response) - async function pull (msg) { - if (msg.data) { - const chunk = (await asyncIterator.next()).value - respondWith(chunk) - if (!chunk) port.onmessage = null - } else { - console.log('Closing stream') - stream.destroy() - port.onmessage = null - } - } - - port.onmessage = pull -}) - -function prettyBytes (num) { - const neg = num < 0; const units = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] - if (neg) num = -num - if (num < 1) return (neg ? '-' : '') + num + ' B' - const exponent = Math.min(Math.floor(Math.log(num) / Math.log(1000)), units.length - 1) - num = Number((num / Math.pow(1000, exponent)).toFixed(2)) - const unit = units[exponent] - return (neg ? '-' : '') + num + ' ' + unit -} diff --git a/app/test.html b/app/test.html index f5fd4f7..f95eae9 100644 --- a/app/test.html +++ b/app/test.html @@ -1,6 +1,7 @@