This commit is contained in:
ThaUnknown 2022-03-12 04:50:32 +01:00
parent 12fd3cdcfc
commit 8f7c943def
10 changed files with 1897 additions and 9 deletions

View file

@ -15,6 +15,7 @@
"concurrently": "^7.0.0",
"electron": "^16.0.10",
"electron-builder": "^22.14.13",
"matroska-subtitles": "^3.3.2",
"svelte": "^3.46.4",
"vite": "^2.8.6",
"vite-plugin-commonjs-externals": "^0.1.1",

82
src/renderer/public/sw.js Normal file
View file

@ -0,0 +1,82 @@
self.addEventListener('install', () => {
self.skipWaiting()
})
self.addEventListener('fetch', event => {
const res = proxyResponse(event)
if (res) event.respondWith(res)
})
self.addEventListener('activate', evt => {
evt.waitUntil(self.clients.claim())
})
const portTimeoutDuration = 5000
function proxyResponse (event) {
const { url } = event.request
if (!(url.includes(self.registration.scope) && url.includes('/webtorrent/')) || url.includes('?')) return null
if (url.includes(self.registration.scope) && url.includes('/webtorrent/keepalive/')) return new Response()
return serve(event)
}
async function serve ({ request }) {
const { url, method, headers, destination } = request
const clientlist = await clients.matchAll({ type: 'window', includeUncontrolled: true })
const [data, port] = await new Promise(resolve => {
// Use race condition for whoever controls the response stream
for (const client of clientlist) {
const messageChannel = new MessageChannel()
const { port1, port2 } = messageChannel
port1.onmessage = ({ data }) => {
resolve([data, port1])
}
client.postMessage({
url,
method,
headers: Object.fromEntries(headers.entries()),
scope: self.registration.scope,
destination,
type: 'webtorrent'
}, [port2])
}
})
if (data.body !== 'STREAM' && data.body !== 'DOWNLOAD') return new Response(data.body, data)
let timeOut = null
return new Response(new ReadableStream({
pull (controller) {
return new Promise(resolve => {
port.onmessage = ({ data }) => {
if (data) {
controller.enqueue(data) // event.data is Uint8Array
} else {
clearTimeout(timeOut)
controller.close() // event.data is null, means the stream ended
port.onmessage = null
}
resolve()
}
// 'media player' does NOT signal a close on the stream and we cannot close it because it's locked to the reader,
// so we just empty it after 5s of inactivity, the browser will request another port anyways
clearTimeout(timeOut)
if (data.body === 'STREAM') {
timeOut = setTimeout(() => {
controller.close()
port.postMessage(false) // send timeout
port.onmessage = null
resolve()
}, portTimeoutDuration)
}
port.postMessage(true) // send a pull request
})
},
cancel () {
// This event is never executed
port.postMessage(false) // send a cancel request
}
}), data)
}

View file

@ -3,17 +3,23 @@
import Player from './pages/Player.svelte'
import Settings from './pages/Settings.svelte'
import Schedule from './pages/Schedule.svelte'
import { client } from '@/modules/torrent.js'
export let page = 'home'
let files = []
client.on('torrent', torrent => {
console.log('hash', torrent.infoHash)
page = 'player'
files = torrent.files
})
</script>
<div class="overflow-y-hidden content-wrapper">
{#if page === 'player'}
<Player />
{:else if page === 'schedule'}
<Player {files} />
{#if page === 'schedule'}
<Schedule />
{:else if page === 'settings'}
<Settings />
{:else}
{:else if page === 'home'}
<Home />
{/if}
</div>

View file

@ -0,0 +1,138 @@
<div class="d-flex flex-column w-820 p-10 rounded">
<div class="row">
<div><div class="material-icons bg-dark">help_outline</div></div>
<div><div></div></div>
<div><div></div></div>
<div><div></div></div>
<div><div></div></div>
<div><div></div></div>
<div><div></div></div>
<div><div></div></div>
<div><div></div></div>
<div><div></div></div>
<div><div></div></div>
<div><div></div></div>
<div><div></div></div>
<div class="w-100"><div class="bg-dark"></div></div>
<div><div class="bg-dark"></div></div>
</div>
<div class="row">
<div class="w-75"><div class="bg-dark"></div></div>
<div><div></div></div>
<div><div></div></div>
<div><div></div></div>
<div><div>-90</div></div>
<div><div></div></div>
<div><div></div></div>
<div><div></div></div>
<div><div class="material-icons">list</div></div>
<div><div></div></div>
<div><div class="material-icons">picture_in_picture</div></div>
<div><div class="material-icons">history</div></div>
<div><div class="material-icons shadow-lg">update</div></div>
<div class="w-75"><div class="material-icons bg-dark">schedule</div></div>
<div><div class="bg-dark"></div></div>
</div>
<div class="row">
<div class="w-90"><div class="bg-dark"></div></div>
<div><div></div></div>
<div><div>+90</div></div>
<div><div class="material-icons">cast</div></div>
<div><div class="material-icons">fullscreen</div></div>
<div><div></div></div>
<div><div></div></div>
<div><div></div></div>
<div><div></div></div>
<div><div></div></div>
<div><div></div></div>
<div><div></div></div>
<div class="w-110"><div class="bg-dark"></div></div>
<div><div class="bg-dark"></div></div>
</div>
<div class="row">
<div class="w-115"><div class="bg-dark"></div></div>
<div><div></div></div>
<div><div></div></div>
<div><div class="material-icons">subtitles</div></div>
<div><div></div></div>
<div><div></div></div>
<div><div class="material-icons">skip_next</div></div>
<div><div class="material-icons">volume_off</div></div>
<div><div class="material-icons">fast_rewind</div></div>
<div><div class="material-icons">fast_forward</div></div>
<div><div></div></div>
<div class="w-85"><div class="bg-dark"></div></div>
<div><div class="material-icons bg-dark">volume_up</div></div>
<div><div class="bg-dark"></div></div>
</div>
<div class="row">
<div class="w-75"><div class="bg-dark"></div></div>
<div><div class="bg-dark"></div></div>
<div class="w-75"><div class="bg-dark"></div></div>
<div class="w-300"><div class="material-icons bg-dark">play_arrow</div></div>
<div class="w-75"><div class="bg-dark"></div></div>
<div class="w-75"><div class="bg-dark"></div></div>
<div><div class="bg-dark">-2</div></div>
<div><div class="material-icons bg-dark">volume_down</div></div>
<div><div class="bg-dark">+2</div></div>
</div>
</div>
<style>
.material-icons, .row > div > div {
font-size: 1.8rem;
font-weight: bold;
display: flex;
justify-content: center;
align-items: center;
}
.material-icons {
font-size: 2.2rem !important;
font-weight: unset !important;
}
.row > div {
height: 5rem;
width: 5rem;
padding: 0.5rem;
transition: transform 0.2s ease;
}
.row > div:hover {
transform: scale(0.9);
}
.row > div > div {
background-color: var(--dark-color-light);
height: 100%;
border-radius: var(--base-border-radius) !important;
}
.w-75 {
width: 7.5rem !important;
}
.w-85 {
width: 8.5rem !important;
}
.w-90 {
width: 9rem !important;
}
.w-110 {
width: 11rem !important;
}
.w-115 {
width: 11.5rem !important;
}
.w-820 {
width: 82rem;
animation: .3s ease 0s 1 load-in;
background: rgba(0, 0, 0, 0.8)
}
@keyframes load-in {
from {
bottom: -1.2rem;
transform: scale(.75);
}
to {
bottom: 0;
transform: scale(1);
}
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,197 @@
/* eslint-env browser */
function waitToCompleteIceGathering (pc, state = pc.iceGatheringState) {
return state !== 'complete' && new Promise(resolve => {
pc.addEventListener('icegatheringstatechange', () => (pc.iceGatheringState === 'complete') && resolve())
})
}
/**
* @typedef {AddEventListenerOptions} Test~options
* @property {AbortSignal} signal - funkis?
*/
export default class Peer {
/**
* @param {{
* polite: boolean,
* trickle: boolean,
* iceServers: RTCIceServer[]
* signal: AbortSignal
* }} [options]
*/
constructor (options = {}) {
let { polite = true, trickle = true, quality = {} } = options
let { port1, port2 } = new MessageChannel()
let send = msg => port2.postMessage(JSON.stringify(msg))
const pc = new RTCPeerConnection({
iceServers: options?.iceServers || [{
urls: [
'stun:stun.l.google.com:19302',
'stun:global.stun.twilio.com:3478'
]
}]
})
const ctrl = new AbortController()
/** @type {any} dummy alias for AbortSignal to make TS happy */
const signal = { signal: ctrl.signal }
pc.addEventListener('iceconnectionstatechange', () => {
if (
pc.iceConnectionState === 'disconnected' ||
pc.iceConnectionState === 'failed'
) {
ctrl.abort()
}
}, signal)
const dc = pc.createDataChannel('both', { negotiated: true, id: 0 })
this.pc = pc
this.dc = dc
this.signal = ctrl.signal
this.polite = polite
this.signalingPort = port1
this.ready = new Promise(resolve => {
dc.addEventListener('open', () => {
// At this point we start to trickle over datachannel instead
// we also close the message channel as we do not need it anymore
trickle = true
send = (msg) => dc.send(JSON.stringify(msg))
port1.close()
port2.close()
this.ready = port2 = port1 = port2.onmessage = null
resolve()
}, { once: true, ...signal })
})
pc.addEventListener('icecandidate', ({ candidate }) => {
trickle && send({ candidate })
}, { ...signal })
// The rest is the polite peer negotiation logic, copied from this blog
let makingOffer = false; let ignoreOffer = false
pc.addEventListener('negotiationneeded', async () => {
makingOffer = true
const offer = await pc.createOffer()
if (pc.signalingState !== 'stable') return
offer.sdp = setQuality(offer.sdp, quality)
await pc.setLocalDescription(offer)
makingOffer = false
if (trickle) {
send({ description: pc.localDescription })
} else {
await waitToCompleteIceGathering(pc)
const description = pc.localDescription.toJSON()
description.sdp = description.sdp.replace(/a=ice-options:trickle\s\n/g, '')
send({ description })
}
}, { ...signal })
async function onmessage ({ data }) {
const { description, candidate } = typeof data === 'string' ? JSON.parse(data) : data
if (description) {
const offerCollision = description.type === 'offer' &&
(makingOffer || pc.signalingState !== 'stable')
ignoreOffer = !this.polite && offerCollision
if (ignoreOffer) {
return
}
if (offerCollision) {
await Promise.all([
pc.setLocalDescription({ type: 'rollback' }),
pc.setRemoteDescription(description)
])
} else {
try {
(description.type === 'answer' && pc.signalingState === 'stable') ||
await pc.setRemoteDescription(description)
} catch (err) { }
}
if (description.type === 'offer') {
const answ = await pc.createAnswer()
answ.sdp = setQuality(answ.sdp, quality)
await pc.setLocalDescription(answ)
// Edge didn't set the state to 'new' after calling the above :[
if (!trickle) await waitToCompleteIceGathering(pc, 'new')
send({ description: pc.localDescription })
}
} else if (candidate) {
await pc.addIceCandidate(candidate)
}
}
port2.onmessage = onmessage.bind(this)
dc.addEventListener('message', onmessage.bind(this), { ...signal })
}
}
// I cannot describe the sheer anger and hatred I bear towards whoever came up with SDP
function setQuality (sdp, opts = {}) {
if (!sdp || (!opts.video && !opts.audio)) return sdp
let newSDP = sdp
if (opts.video) { // bitrate, codecs[]
const videoData = sdp.matchAll(/^m=video.*SAVPF (.*)$/gm).next().value
if (videoData && videoData[1]) {
const RTPIndex = videoData[1]
const RTPMaps = {}
let last = null
for (const [match, id, type] of [...sdp.matchAll(/^a=rtpmap:(\d{1,3}) (.*)\/90000$/gm)]) {
if (type === 'rtx') {
RTPMaps[last].push(id)
} else {
if (!RTPMaps[type]) RTPMaps[type] = []
RTPMaps[type].push(id)
last = type
if (opts.video.bitrate) {
const fmtp = `a=fmtp:${id} x-google-min-bitrate=${opts.video.bitrate}; x-google-max-bitrate=${opts.video.bitrate}\n`
newSDP = newSDP.replace(match, fmtp + match)
}
}
}
const newIndex = Object.entries(RTPMaps).sort((a, b) => {
const indexA = opts.video.codecs.indexOf(a[0])
const indexB = opts.video.codecs.indexOf(b[0])
return (indexA === -1 ? opts.video.codecs.length : indexA) - (indexB === -1 ? opts.video.codecs.length : indexB)
}).map(value => {
return value[1].join(' ')
}).join(' ')
newSDP = newSDP.replace(RTPIndex, newIndex)
}
}
if (opts.audio) {
const audioData = sdp.matchAll(/^a=rtpmap:(\d{1,3}) opus\/48000\/2$/gm).next().value
if (audioData && audioData[0]) {
const regex = new RegExp(`^a=fmtp:${audioData[1]}.*$`, 'gm')
const FMTPData = sdp.match(regex)
if (FMTPData && FMTPData[0]) {
let newFMTPData = FMTPData[0].slice(0, FMTPData[0].indexOf(' ') + 1)
newFMTPData += 'stereo=' + (opts.audio.stereo != null ? opts.audio.stereo : '1')
newFMTPData += ';sprop-stereo=' + (opts.audio['sprop-stereo'] != null ? opts.audio['sprop-stereo'] : '1')
if (opts.audio.maxaveragebitrate != null) newFMTPData += '; maxaveragebitrate=' + (opts.audio.maxaveragebitrate || 128 * 1024 * 8)
if (opts.audio.maxplaybackrate != null) newFMTPData += '; maxplaybackrate=' + (opts.audio.maxplaybackrate || 128 * 1024 * 8)
if (opts.audio.cbr != null) newFMTPData += '; cbr=' + opts.audio.cbr
if (opts.audio.useinbandfec != null) newFMTPData += '; useinbandfec=' + opts.audio.useinbandfec
if (opts.audio.usedtx != null) newFMTPData += '; usedtx=' + opts.audio.usedtx
if (opts.audio.maxptime != null) newFMTPData += ';maxptime:' + opts.audio.maxptime
if (opts.audio.minptime != null) newFMTPData += '; minptime:' + opts.audio.minptime
newSDP = newSDP.replace(FMTPData[0], newFMTPData)
}
}
}
return newSDP
}

View file

@ -0,0 +1,320 @@
/* eslint-env browser */
import SubtitlesOctopus from './subtitles-octopus.js'
import { toTS, videoRx, subRx } from './util.js'
import { SubtitleParser, SubtitleStream } from 'matroska-subtitles'
const 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, Roboto Medium,26,&H00FFFFFF,&H000000FF,&H00020713,&H00000000,0,0,0,0,100,100,0,0,1,1.3,0,2,20,20,23,1
[Events]
`
export default class Subtitles {
constructor (video, files, selected, onHeader) {
this.video = video
this.selected = selected || null
this.files = files || []
this.headers = []
this.tracks = []
this.fonts = ['Roboto.ttf']
this.renderer = null
this.parsed = false
this.stream = null
this.parser = null
this.current = 0
this.onHeader = onHeader
this.videoFiles = files.filter(file => videoRx.test(file.name))
this.subtitleFiles = []
this.timeout = null
if (this.selected.name.endsWith('.mkv') && this.selected.createReadStream) {
let lastStream = null
this.selected.on('stream', ({ stream }) => { lastStream = stream })
this.initParser(this.selected).then(() => {
this.selected.on('stream', ({ stream, file, req }, cb) => {
if (req.destination === 'video' && !this.parsed) {
this.stream = new SubtitleStream(this.stream)
this.handleSubtitleParser(this.stream, true)
stream.pipe(this.stream)
cb(this.stream)
}
})
lastStream?.destroy()
})
}
this.findSubtitleFiles(this.selected)
}
findSubtitleFiles (targetFile) {
const videoName = targetFile.name.substring(0, targetFile.name.lastIndexOf('.')) || targetFile.name
// array of subtitle files that match video name, or all subtitle files when only 1 vid file
const subfiles = this.files.filter(file => {
return !this.subtitleFiles.some(sub => { // exclude already existing files
return sub.lastModified === file.lastModified && sub.name === file.name && sub.size === file.size
}) && subRx.test(file.name) && (this.videoFiles.length === 1 ? true : file.name.includes(videoName))
})
if (subfiles.length) {
this.parsed = true
const length = this.headers.length
for (const [i, file] of subfiles.entries()) {
const index = i + length
this.subtitleFiles[index] = file
const type = file.name.substring(file.name.lastIndexOf('.') + 1).toLowerCase()
const subname = file.name.slice(0, file.name.lastIndexOf('.'))
// sub name could contain video name with or without extension, possibly followed by lang, or not.
const name = subname.includes(targetFile.name)
? subname.replace(targetFile.name, '')
: subname.replace(targetFile.name.slice(0, targetFile.name.lastIndexOf('.')), '')
this.headers[index] = {
header: defaultHeader,
language: name.replace(/[,._-]/g, ' ').trim() || 'Track ' + index,
number: index,
type
}
this.onHeader()
this.tracks[index] = []
this.constructor.convertSubFile(file, type, subtitles => { // why does .constructor work ;-;
if (type === 'ass') {
this.headers[index].header = subtitles
} else {
this.tracks[index] = subtitles
}
})
}
if (!this.current) {
this.current = 0
if (!this.renderer) this.initSubtitleRenderer()
this.selectCaptions(this.current)
this.onHeader()
}
}
}
async initSubtitleRenderer () {
if (!this.renderer) {
const options = {
video: this.video,
subContent: this.headers[this.current].header.slice(0, -1),
fonts: this.fonts,
workerUrl: 'lib/subtitles-octopus-worker.js'
}
if (!this.renderer) {
this.renderer = new SubtitlesOctopus(options)
this.selectCaptions(this.current)
}
}
}
static convertSubFile (file, type, callback) {
const srtRx = /(?:\d+\n)?(\S{9,12})\s?-->\s?(\S{9,12})(.*)\n([\s\S]*)$/i
const srt = text => {
const subtitles = []
const replaced = text.replace(/\r/g, '')
for (const split of replaced.split('\n\n')) {
const match = split.match(srtRx)
if (match) {
// timestamps
match[1] = match[1].match(/.*[.,]\d{2}/)[0]
match[2] = match[2].match(/.*[.,]\d{2}/)[0]
if (match[1].length === 9) {
match[1] = '0:' + match[1]
} else {
if (match[1][0] === '0') {
match[1] = match[1].substring(1)
}
}
match[1].replace(',', '.')
if (match[2].length === 9) {
match[2] = '0:' + match[2]
} else {
if (match[2][0] === '0') {
match[2] = match[2].substring(1)
}
}
match[2].replace(',', '.')
// create array of all tags
const matches = match[4].match(/<[^>]+>/g)
if (matches) {
matches.forEach(matched => {
if (/<\//.test(matched)) { // check if its a closing tag
match[4] = match[4].replace(matched, matched.replace('</', '{\\').replace('>', '0}'))
} else {
match[4] = match[4].replace(matched, matched.replace('<', '{\\').replace('>', '1}'))
}
})
}
subtitles.push('Dialogue: 0,' + match[1].replace(',', '.') + ',' + match[2].replace(',', '.') + ',Default,,0,0,0,,' + match[4])
}
}
callback(subtitles)
}
const subRx = /[{[](\d+)[}\]][{[](\d+)[}\]](.+)/i
const sub = text => {
const subtitles = []
const replaced = text.replace(/\r/g, '')
let frames = 1000 / Number(replaced.match(subRx)[3])
if (!frames || isNaN(frames)) frames = 41.708
for (const split of replaced.split('\n')) {
const match = split.match(subRx)
if (match) subtitles.push('Dialogue: 0,' + toTS((match[1] * frames) / 1000, 1) + ',' + toTS((match[2] * frames) / 1000, 1) + ',Default,,0,0,0,,' + match[3].replace('|', '\n'))
}
callback(subtitles)
}
file.text().then(text => {
const subtitles = type === 'ass' ? text : []
if (type === 'ass') {
callback(subtitles)
} else if (type === 'srt' || type === 'vtt') {
srt(text)
} else if (type === 'sub') {
sub(text)
} else {
// subbers have a tendency to not set the extensions properly
if (srtRx.test(text)) srt(text)
if (subRx.test(text)) sub(text)
}
})
}
static constructSub (subtitle, isNotAss) {
if (isNotAss === true) { // 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('</', '{\\').replace('>', '0}'))
} else {
subtitle.text = subtitle.text.replace(match, match.replace('<', '{\\').replace('>', '1}'))
}
})
}
// replace all html special tags with normal ones
subtitle.text.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&nbsp;/g, '\\h')
}
return 'Dialogue: ' +
(subtitle.layer || 0) + ',' +
toTS(subtitle.time / 1000, 1) + ',' +
toTS((subtitle.time + subtitle.duration) / 1000, 1) + ',' +
(subtitle.style || 'Default') + ',' +
(subtitle.name || '') + ',' +
(subtitle.marginL || '0') + ',' +
(subtitle.marginR || '0') + ',' +
(subtitle.marginV || '0') + ',' +
(subtitle.effect || '') + ',' +
subtitle.text || ''
}
parseSubtitles () { // parse all existing subtitles for a file
return new Promise((resolve) => {
if (this.selected.name.endsWith('.mkv')) {
let parser = new SubtitleParser()
this.handleSubtitleParser(parser, true)
const finish = () => {
console.log('Sub parsing finished', toTS((performance.now() - t0) / 1000))
this.parsed = true
this.stream?.destroy()
fileStream?.destroy()
this.parser?.destroy()
this.stream = undefined
this.parser = undefined
this.selectCaptions(this.current)
parser = undefined
resolve()
}
parser.once('tracks', tracks => {
if (!tracks.length) finish()
})
parser.once('finish', finish)
const t0 = performance.now()
console.log('Sub parsing started')
const fileStream = this.selected.createReadStream()
this.parser = fileStream.pipe(parser)
} else {
resolve()
}
})
}
initParser (file) {
return new Promise(resolve => {
this.stream = new SubtitleParser()
this.handleSubtitleParser(this.stream)
this.stream.once('tracks', tracks => {
if (!tracks.length) {
this.parsed = true
resolve()
this.stream.destroy()
fileStreamStream.destroy()
}
})
this.stream.once('subtitle', () => {
resolve()
fileStreamStream.destroy()
})
const fileStreamStream = file.createReadStream()
fileStreamStream.pipe(this.stream)
})
}
handleSubtitleParser (parser, skipFile) {
parser.once('tracks', tracks => {
if (!tracks.length) {
this.parsed = true
parser?.destroy()
} else {
for (const track of tracks) {
if (!this.tracks[track.number]) {
// overwrite webvtt or other header with custom one
if (track.type !== 'ass') track.header = defaultHeader
if (!this.current) {
this.current = track.number
}
this.tracks[track.number] = new Set()
this.headers[track.number] = track
this.onHeader()
}
}
}
})
parser.on('subtitle', (subtitle, trackNumber) => {
if (!this.parsed) {
if (!this.renderer) this.initSubtitleRenderer()
this.tracks[trackNumber].add(this.constructor.constructSub(subtitle, this.headers[trackNumber].type !== 'ass'))
if (this.current === trackNumber) this.selectCaptions(trackNumber) // yucky
}
})
if (!skipFile) {
parser.on('file', file => {
if (file.mimetype === 'application/x-truetype-font' || file.mimetype === 'application/font-woff' || file.mimetype === 'application/vnd.ms-opentype') {
this.fonts.push(URL.createObjectURL(new Blob([file.data], { type: file.mimetype })))
}
})
}
}
selectCaptions (trackNumber) {
if (trackNumber !== undefined) {
this.current = Number(trackNumber)
this.onHeader()
if (!this.timeout) {
this.timeout = setTimeout(() => {
this.timeout = undefined
if (this.renderer && this.headers) this.renderer.setTrack(this.current !== -1 ? this.headers[this.current].header.slice(0, -1) + Array.from(this.tracks[this.current]).join('\n') : defaultHeader)
}, 1000)
}
}
}
destroy () {
this.stream?.destroy()
this.parser?.destroy()
this.renderer?.destroy()
this.files = null
this.video = null
this.selected = null
this.tracks = null
this.headers = null
this.onHeader()
this.fonts?.forEach(file => URL.revokeObjectURL(file))
}
}

View file

@ -1,8 +1,32 @@
const WebTorrent = require('webtorrent')
import WebTorrent from 'webtorrent'
export const client = new WebTorrent()
window.client = client
// save loaded torrent for persistence
const scope = location.pathname.substr(0, location.pathname.lastIndexOf('/') + 1)
const worker = location.origin + scope + 'sw.js' === navigator.serviceWorker?.controller?.scriptURL && navigator.serviceWorker.controller
const handleWorker = worker => {
const checkState = worker => {
return worker.state === 'activated' && client.loadWorker(worker)
}
if (!checkState(worker)) {
worker.addEventListener('statechange', ({ target }) => checkState(target))
}
}
if (worker) {
handleWorker(worker)
} else {
navigator.serviceWorker.register('sw.js', { scope }).then(reg => {
handleWorker(reg.active || reg.waiting || reg.installing)
}).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
}
})
}
export function add (torrentID) {
if (torrentID) {
if (client.torrents.length) client.remove(client.torrents[0].infoHash)

View file

@ -10,8 +10,36 @@ export function countdown (s) {
if (d || h) tmp.push(h + 'h')
if (d || h || m) tmp.push(m + 'm')
return tmp.join(' ')
}
export const DOMPARSER = DOMParser.prototype.parseFromString.bind(new DOMParser())
export const sleep = t => new Promise(resolve => setTimeout(resolve, t))
export 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']
export const videoRx = new RegExp(`.(${videoExtensions.join('|')})$`, 'i')
export const subtitleExtensions = ['srt', 'vtt', 'ass', 'ssa', 'sub', 'txt']
export const subRx = new RegExp(`.(${subtitleExtensions.join('|')})$`, 'i')
export function toTS (sec, full) {
if (isNaN(sec) || sec < 0) {
switch (full) {
case 1:
return '0:00:00.00'
case 2:
return '0:00:00'
case 3:
return '00:00'
default:
return '0:00'
}
}
const hours = Math.floor(sec / 3600)
let minutes = Math.floor(sec / 60) - hours * 60
let seconds = full === 1 ? (sec % 60).toFixed(2) : Math.floor(sec % 60)
if (minutes < 10 && (hours > 0 || full)) minutes = '0' + minutes
if (seconds < 10) seconds = '0' + seconds
return (hours > 0 || full === 1 || full === 2) ? hours + ':' + minutes + ':' + seconds : minutes + ':' + seconds
}

View file

@ -1,7 +1,6 @@
import path from 'path'
import process from 'process'
import { defineConfig } from 'vite'
// import builtinModules from 'builtin-modules'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import commonjsExternals from 'vite-plugin-commonjs-externals'
@ -12,18 +11,17 @@ const commonjsPackages = [
// 'electron/renderer',
// 'original-fs',
'webtorrent',
'buffer'
// ...builtinModules
'matroska-subtitles'
]
// https://vitejs.dev/config/
export default defineConfig({
plugins: [svelte(), commonjsExternals({ externals: commonjsPackages })],
resolve: {
alias: {
'@': path.resolve('src/renderer/src')
}
},
plugins: [commonjsExternals({ externals: commonjsPackages }), svelte()],
root: path.resolve(process.cwd(), 'src/renderer'),
base: './'
})